package particle import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/flowy-live/llink/internal/utils" "github.com/jackc/pgx/v5/pgxpool" ) const defaultPageSize = 50 // Deprecated: particle data now lives in Firestore. The Postgres-backed // service is retained only for legacy paths. type Service interface { // Create returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, // ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. Network // membership is verified by the handler. Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error) // GetByID returns ErrNotFound or ErrAccessDenied. GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error) // Update returns ErrNotFound, ErrAccessDenied, or ErrInvalidData. Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error) // Delete returns ErrNotFound or ErrAccessDenied. Delete(ctx context.Context, id, requesterEmail string) error // List uses parentID=nil for root particles. Returns ErrNotFound or // ErrAccessDenied when parentID is given but inaccessible. List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error) // OpenStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, // ErrStreamAlreadyOpen, or ErrCapacityExceeded. OpenStream(ctx context.Context, id, requesterEmail string) error // CloseStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or // ErrStreamAlreadyClosed. CloseStream(ctx context.Context, id, requesterEmail string) error // SetVisibility returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion. SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error // AddMembers / RemoveMembers operate on custom-visibility streams only. // Both return ErrNotFound or ErrAccessDenied. AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error // Seen tracking is private per human; Ack is public and permanent. MarkSeen(ctx context.Context, id, requesterEmail string) error MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error Ack(ctx context.Context, id, requesterEmail string) error GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error) // Bulk lookups for batch hydration. GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error) GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) } type serviceImpl struct { repo repository networkMembershipChecker NetworkMembershipChecker } func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Service { return &serviceImpl{ repo: newRepository(pool), networkMembershipChecker: networkReader, } } // Walks the ancestor chain when visibility is inherited, stopping at the // first network_all or custom node. Assumes network membership is already // verified by the handler. func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) { ancestors, err := s.repo.getAncestorChain(ctx, particleID) if err != nil { return false, err } if len(ancestors) == 0 { return false, errNotFound } byID := make(map[string]*Particle, len(ancestors)) for _, p := range ancestors { byID[p.ID] = p } // ancestors[0] is the target; walk up only on inherited. current := ancestors[0] for { switch current.Visibility { case VisibilityNetworkAll: return true, nil case VisibilityCustom: return s.repo.isMemberOf(ctx, current.ID, email) case VisibilityInherited: if current.ParentID == nil { // inherited-at-root is invalid; deny. return false, nil } parent, ok := byID[*current.ParentID] if !ok { return false, nil } current = parent default: return false, nil } } } func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error) { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return nil, err } if !isValidParticleType(input.Type) { return nil, ErrInvalidType } if err := validateParticleData(input.Type, input.Data); err != nil { return nil, err } // MVP visibility: children always inherit; roots cannot inherit and // default to network_all. Streams/folders are root-only. if input.ParentID != nil { input.Visibility = VisibilityInherited input.Members = nil if input.Type == TypeStream || input.Type == TypeFolder { return nil, ErrInvalidParent } } else { if input.Visibility == VisibilityInherited { return nil, ErrInheritedAtRoot } if input.Visibility == "" { input.Visibility = VisibilityNetworkAll } } var customMembers []string if input.Visibility == VisibilityCustom { if input.Type != TypeStream { return nil, ErrNotAContainer } if len(input.Members) == 0 { return nil, ErrMembersRequired } // Validate every member upfront so DB writes are all-or-nothing. customMembers = make([]string, 0, len(input.Members)+1) customMembers = append(customMembers, requesterEmail) seen := map[string]bool{requesterEmail: true} for _, email := range input.Members { normalized, err := utils.NormalizeEmail(email) if err != nil { return nil, errors.Join(ErrInvalidData, err) } if seen[normalized] { continue } seen[normalized] = true isMember, err := s.networkMembershipChecker.IsMember(ctx, input.NetworkID, normalized) if err != nil { return nil, err } if !isMember { return nil, fmt.Errorf("%w: %s", ErrInvalidMember, normalized) } customMembers = append(customMembers, normalized) } } // Network membership is verified by the handler; only particle visibility is checked here. if input.ParentID != nil { hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return nil, ErrInvalidParent } return nil, err } if !hasAccess { return nil, ErrAccessDenied } } p := &Particle{ Type: input.Type, NetworkID: input.NetworkID, ParentID: input.ParentID, CreatedByEmail: requesterEmail, Visibility: input.Visibility, Data: input.Data, } if p.Data == nil { p.Data = json.RawMessage("{}") } // New streams default to open. if input.Type == TypeStream { data, err := setStreamStatus(p.Data, string(StreamStatusOpen)) if err != nil { return nil, err } p.Data = data } created, err := s.repo.create(ctx, p) if err != nil { return nil, err } if len(customMembers) > 0 { if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil { return nil, err } } return created, nil } func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error) { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return nil, err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return nil, ErrNotFound } return nil, err } if !hasAccess { return nil, ErrAccessDenied } p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return nil, ErrNotFound } return nil, err } return p, nil } func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error) { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return nil, err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return nil, ErrNotFound } return nil, err } if !hasAccess { return nil, ErrAccessDenied } p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return nil, ErrNotFound } return nil, err } if err := validateParticleData(p.Type, data); err != nil { return nil, err } err = s.repo.update(ctx, id, data, time.Now()) if err != nil { if errors.Is(err, errNotFound) { return nil, ErrNotFound } return nil, err } return s.repo.getByID(ctx, id) } func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if !hasAccess { return ErrAccessDenied } _, err = s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } err = s.repo.delete(ctx, id) if errors.Is(err, errNotFound) { return ErrNotFound } return err } func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error) { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return nil, err } // Network membership is verified by the handler; only particle visibility is checked here. if parentID != nil { hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return nil, ErrNotFound } return nil, err } if !hasAccess { return nil, ErrAccessDenied } } // Fetch limit+1 to detect a next page; visibility filtering lives in the query. if limit == 0 { limit = defaultPageSize } extraLimit := limit + 1 particles, err := s.repo.list(ctx, networkID, parentID, requesterEmail, filter, extraLimit, cursor) if err != nil { return nil, err } hasMore := len(particles) > limit result := &ParticleList{ HasMore: hasMore, } if hasMore { particles = particles[:limit] } result.Particles = particles // Bidirectional cursors if len(particles) > 0 { firstParticle := particles[0] lastParticle := particles[len(particles)-1] result.PrevCursor = &Cursor{ Position: firstParticle.UpdatedAt.Format(time.RFC3339Nano), Direction: "before", } if result.HasMore { result.NextCursor = &Cursor{ Position: lastParticle.UpdatedAt.Format(time.RFC3339Nano), Direction: "after", } } } return result, nil } func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if !hasAccess { return ErrAccessDenied } p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if p.Type != TypeStream { return ErrNotAStream } if getStreamStatus(p.Data) == string(StreamStatusOpen) { return ErrStreamAlreadyOpen } newData, err := setStreamStatus(p.Data, string(StreamStatusOpen)) if err != nil { return err } err = s.repo.update(ctx, id, newData, time.Now()) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } return nil } func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if !hasAccess { return ErrAccessDenied } p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if p.Type != TypeStream { return ErrNotAStream } if getStreamStatus(p.Data) == string(StreamStatusClosed) { return ErrStreamAlreadyClosed } newData, err := setStreamStatus(p.Data, string(StreamStatusClosed)) if err != nil { return err } err = s.repo.update(ctx, id, newData, time.Now()) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } return nil } func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if !hasAccess { return ErrAccessDenied } p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if mode == VisibilityInherited && p.ParentID == nil { return ErrInheritedAtRoot } // Expanding to network_all is rejected if any ancestor restricts to custom. if mode == VisibilityNetworkAll && p.ParentID != nil { parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID) if err != nil { return err } if parentVis == VisibilityCustom { return ErrAccessExpansion } } err = s.repo.setVisibility(ctx, id, mode) if errors.Is(err, errNotFound) { return ErrNotFound } return err } // Walks up the inherited chain to the concrete visibility node. func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) { ancestors, err := s.repo.getAncestorChain(ctx, particleID) if err != nil { return "", err } if len(ancestors) == 0 { return "", errNotFound } byID := make(map[string]*Particle, len(ancestors)) for _, p := range ancestors { byID[p.ID] = p } current := ancestors[0] for { if current.Visibility != VisibilityInherited { return current.Visibility, nil } if current.ParentID == nil { return VisibilityNetworkAll, nil } parent, ok := byID[*current.ParentID] if !ok { return VisibilityNetworkAll, nil } current = parent } } func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if !hasAccess { return ErrAccessDenied } p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if p.Type != TypeStream { return ErrNotAContainer } // Validate every email upfront so any failure aborts before DB writes. normalizedEmails := make([]string, 0, len(emails)) seen := make(map[string]bool, len(emails)) for _, email := range emails { normalized, err := utils.NormalizeEmail(email) if err != nil { return errors.Join(ErrInvalidData, err) } if seen[normalized] { continue } seen[normalized] = true isMember, err := s.networkMembershipChecker.IsMember(ctx, p.NetworkID, normalized) if err != nil { return err } if !isMember { return fmt.Errorf("%w: %s", ErrInvalidMember, normalized) } normalizedEmails = append(normalizedEmails, normalized) } if len(normalizedEmails) == 0 { return nil } return s.repo.addMembers(ctx, id, normalizedEmails) } func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if !hasAccess { return ErrAccessDenied } p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if p.Type != TypeStream { return ErrNotAContainer } normalizedEmails := make([]string, 0, len(emails)) for _, email := range emails { normalized, err := utils.NormalizeEmail(email) if err != nil { continue } normalizedEmails = append(normalizedEmails, normalized) } if len(normalizedEmails) == 0 { return nil } return s.repo.removeMembers(ctx, id, normalizedEmails) } func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if !hasAccess { return ErrAccessDenied } return s.repo.markSeen(ctx, id, requesterEmail) } func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } // Silently skip particles that are missing or inaccessible. for _, id := range ids { hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { continue } return err } if !hasAccess { continue } if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil { return err } } return nil } func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return err } hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound } return err } if !hasAccess { return ErrAccessDenied } // Ack implies seen. if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil { return err } return s.repo.ack(ctx, id, requesterEmail) } func (s *serviceImpl) GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error) { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return nil, err } return s.repo.getUnseenCounts(ctx, streamIDs, requesterEmail) } func (s *serviceImpl) GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error) { requesterEmail, err := utils.NormalizeEmail(requesterEmail) if err != nil { return nil, err } return s.repo.getSeenMap(ctx, particleIDs, requesterEmail) } func (s *serviceImpl) GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) { return s.repo.getAcksMap(ctx, particleIDs) } func (s *serviceImpl) GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) { return s.repo.getMembersMap(ctx, particleIDs) } func isValidParticleType(t ParticleType) bool { switch t { case TypeStream, TypeFolder, TypeMedia, TypeFile, TypeText, TypeQuest, TypePaper: return true default: return false } } func getStreamStatus(data json.RawMessage) string { var d StreamData if err := json.Unmarshal(data, &d); err != nil { return "" } return d.Status } func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) { var d StreamData if err := json.Unmarshal(data, &d); err != nil { d = StreamData{} } d.Status = status return json.Marshal(d) } // Returns ErrInvalidData if data is not valid JSON or is missing required // fields for pType. Empty/null data is allowed and treated as {}. func validateParticleData(pType ParticleType, data json.RawMessage) error { if len(data) == 0 || string(data) == "null" || string(data) == "{}" { return nil } switch pType { case TypeStream: var d StreamData if err := json.Unmarshal(data, &d); err != nil { return errors.Join(ErrInvalidData, err) } if d.Name == "" { return errors.Join(ErrInvalidData, errors.New("stream requires name")) } if d.Status != string(StreamStatusOpen) && d.Status != string(StreamStatusClosed) { return errors.Join(ErrInvalidData, errors.New("stream requires valid status")) } case TypeFolder: var d FolderData if err := json.Unmarshal(data, &d); err != nil { return errors.Join(ErrInvalidData, err) } if d.Name == "" { return errors.Join(ErrInvalidData, errors.New("folder requires name")) } case TypeMedia: var d MediaData if err := json.Unmarshal(data, &d); err != nil { return errors.Join(ErrInvalidData, err) } if d.ObjectID == "" { return errors.Join(ErrInvalidData, errors.New("media requires object_id")) } if d.MimeType == "" { return errors.Join(ErrInvalidData, errors.New("media requires mime_type")) } if d.DurationMs <= 0 { return errors.Join(ErrInvalidData, errors.New("media requires positive duration_ms")) } case TypeFile: var d FileData if err := json.Unmarshal(data, &d); err != nil { return errors.Join(ErrInvalidData, err) } if d.ObjectID == "" { return errors.Join(ErrInvalidData, errors.New("file requires object_id")) } if d.Filename == "" { return errors.Join(ErrInvalidData, errors.New("file requires filename")) } if d.MimeType == "" { return errors.Join(ErrInvalidData, errors.New("file requires mime_type")) } if d.Size <= 0 { return errors.Join(ErrInvalidData, errors.New("file requires non-negative size")) } case TypeText: var d TextData if err := json.Unmarshal(data, &d); err != nil { return errors.Join(ErrInvalidData, err) } if d.Content == "" { return errors.Join(ErrInvalidData, errors.New("text requires content")) } case TypeQuest: var d QuestData if err := json.Unmarshal(data, &d); err != nil { return errors.Join(ErrInvalidData, err) } if d.Title == "" { return errors.Join(ErrInvalidData, errors.New("quest requires title")) } if d.Description == "" { return errors.Join(ErrInvalidData, errors.New("quest requires description")) } case TypePaper: var d PaperData if err := json.Unmarshal(data, &d); err != nil { return errors.Join(ErrInvalidData, err) } if d.Title == "" { return errors.Join(ErrInvalidData, errors.New("paper requires title")) } if d.Content == "" { return errors.Join(ErrInvalidData, errors.New("paper requires content")) } } return nil }