* refactor: update api and client to reference humanIds * fix: prevent deletion of network member This may cause various side effects if there is data in other services which reference this member
916 lines
24 KiB
Go
916 lines
24 KiB
Go
package particle
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/flowy-live/llink/internal/network"
|
|
"github.com/flowy-live/llink/internal/utils"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const defaultPageSize = 50
|
|
|
|
// NOTE: this service is deprecated as we use firestore for particle data
|
|
type Service interface {
|
|
// Create creates a new particle. Caller must be a network member (verified by handler).
|
|
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded.
|
|
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 updates the particle's data. 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 returns particles in a network. Use parentID=nil for root particles.
|
|
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
|
|
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
|
|
|
|
// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
|
OpenStream(ctx context.Context, id, requesterEmail string) error
|
|
// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed.
|
|
CloseStream(ctx context.Context, id, requesterEmail string) error
|
|
|
|
// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
|
|
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
|
|
// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
|
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
|
// RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
|
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
|
|
|
// Seen tracking (private)
|
|
MarkSeen(ctx context.Context, id, requesterEmail string) error
|
|
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
|
|
|
|
// Ack tracking (public, permanent)
|
|
Ack(ctx context.Context, id, requesterEmail string) error
|
|
|
|
// Unseen counts for stream list view
|
|
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
|
|
|
|
// Bulk lookups for handler enrichment
|
|
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
|
|
networkSvc network.Service
|
|
}
|
|
|
|
func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service {
|
|
return &serviceImpl{
|
|
repo: newRepository(pool),
|
|
networkSvc: networkSvc,
|
|
}
|
|
}
|
|
|
|
// checkAccess verifies that the email has access to the particle based on visibility.
|
|
// Assumes the caller is already verified as a network member (handler responsibility).
|
|
// Walks up the ancestor chain only when visibility is inherited, stopping at the first
|
|
// network_all or custom node.
|
|
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
|
|
}
|
|
|
|
// Build lookup map by ID
|
|
byID := make(map[string]*Particle, len(ancestors))
|
|
for _, p := range ancestors {
|
|
byID[p.ID] = p
|
|
}
|
|
|
|
// Start from the target particle (first in chain) and walk up 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 state, deny access
|
|
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
|
|
}
|
|
|
|
// Validate particle type
|
|
if !isValidParticleType(input.Type) {
|
|
return nil, ErrInvalidType
|
|
}
|
|
|
|
// Validate data matches type requirements
|
|
if err := validateParticleData(input.Type, input.Data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// MVP visibility rules:
|
|
// - Child particles (have parent) → always inherited
|
|
// - Root particles (no parent) → cannot be inherited, default network_all
|
|
if input.ParentID != nil {
|
|
// Children always inherit from parent
|
|
input.Visibility = VisibilityInherited
|
|
input.Members = nil // no members on inherited particles
|
|
|
|
// Reject streams and folders as children (MVP: streams are root-level only)
|
|
if input.Type == TypeStream || input.Type == TypeFolder {
|
|
return nil, ErrInvalidParent
|
|
}
|
|
} else {
|
|
// Root particles cannot be inherited
|
|
if input.Visibility == VisibilityInherited {
|
|
return nil, ErrInheritedAtRoot
|
|
}
|
|
if input.Visibility == "" {
|
|
input.Visibility = VisibilityNetworkAll
|
|
}
|
|
}
|
|
|
|
// Custom visibility requires at least one member and must be a stream
|
|
if input.Visibility == VisibilityCustom {
|
|
if input.Type != TypeStream {
|
|
return nil, ErrNotAContainer
|
|
}
|
|
if len(input.Members) == 0 {
|
|
return nil, ErrMembersRequired
|
|
}
|
|
}
|
|
|
|
// Network membership is verified by handler - we only check particle visibility
|
|
// If parent specified, check parent access (visibility-based)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Build the particle
|
|
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("{}")
|
|
}
|
|
|
|
// For streams, set initial status to open and check capacity
|
|
if input.Type == TypeStream {
|
|
// Set status to open in the data JSON
|
|
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Data = data
|
|
}
|
|
|
|
// Create the particle
|
|
created, err := s.repo.create(ctx, p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Add members if custom visibility (only streams for MVP)
|
|
if input.Visibility == VisibilityCustom && len(input.Members) > 0 {
|
|
normalizedEmails := make([]string, 0, len(input.Members)+1)
|
|
// Always include the creator
|
|
normalizedEmails = append(normalizedEmails, requesterEmail)
|
|
for _, email := range input.Members {
|
|
normalized, err := utils.NormalizeEmail(email)
|
|
if err != nil {
|
|
continue // Skip invalid emails
|
|
}
|
|
if normalized == requesterEmail {
|
|
continue // Already added
|
|
}
|
|
normalizedEmails = append(normalizedEmails, normalized)
|
|
}
|
|
if err := s.repo.addMembers(ctx, created.ID, normalizedEmails); 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
|
|
}
|
|
|
|
// Check access
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
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
|
|
}
|
|
|
|
// Get the particle to validate data against its type
|
|
p, err := s.repo.getByID(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Validate data matches type requirements
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
// Get the particle to check if it's an open stream
|
|
_, 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 handler - we only check particle visibility
|
|
// If parentID specified, check access to parent (visibility-based)
|
|
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 one extra to determine if there are more
|
|
// Access filtering is done in the query itself (network_all OR user is member)
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
// Get the particle
|
|
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
|
|
}
|
|
|
|
// Update stream status in data
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
// Get the particle
|
|
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
|
|
}
|
|
|
|
// Update stream status in data
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
// Get the particle to check constraints
|
|
p, err := s.repo.getByID(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Root particles cannot be inherited
|
|
if mode == VisibilityInherited && p.ParentID == nil {
|
|
return ErrInheritedAtRoot
|
|
}
|
|
|
|
// If expanding to network_all, check that parent's effective visibility allows it
|
|
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
|
|
}
|
|
|
|
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
// Get the particle to check type and parent access
|
|
p, err := s.repo.getByID(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Only streams can have members
|
|
if p.Type != TypeStream {
|
|
return ErrNotAContainer
|
|
}
|
|
|
|
// Validate and normalize emails, check network membership
|
|
normalizedEmails := make([]string, 0, len(emails))
|
|
for _, email := range emails {
|
|
normalized, err := utils.NormalizeEmail(email)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Root stream - check network membership
|
|
isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized)
|
|
if err != nil || !isMember {
|
|
continue
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
// Get the particle to check type
|
|
p, err := s.repo.getByID(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Only streams can have members
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
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
|
|
}
|
|
|
|
// Check access for each particle and mark seen
|
|
for _, id := range ids {
|
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
continue // Skip non-existent particles
|
|
}
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
continue // Skip inaccessible particles
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrAccessDenied
|
|
}
|
|
|
|
// Ack also marks as 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
|
|
}
|
|
}
|
|
|
|
// getStreamStatus extracts the status from a stream particle's data
|
|
func getStreamStatus(data json.RawMessage) string {
|
|
var d StreamData
|
|
if err := json.Unmarshal(data, &d); err != nil {
|
|
return ""
|
|
}
|
|
return d.Status
|
|
}
|
|
|
|
// setStreamStatus updates the status in a stream particle's data
|
|
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)
|
|
}
|
|
|
|
// validateParticleData validates that the data field contains valid JSON
|
|
// and has required fields for the given particle type.
|
|
func validateParticleData(pType ParticleType, data json.RawMessage) error {
|
|
// Empty or null data is allowed - will default to {}
|
|
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
|
|
}
|