migrate orion repo into monorepo structure
This commit is contained in:
@@ -0,0 +1,954 @@
|
||||
package particle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const defaultPageSize = 50
|
||||
|
||||
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
|
||||
|
||||
// Check and increment capacity
|
||||
err = s.networkSvc.IncrementOpenStreamCount(ctx, input.NetworkID)
|
||||
if err != nil {
|
||||
if errors.Is(err, network.ErrCapacityExceeded) {
|
||||
return nil, ErrCapacityExceeded
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create the particle
|
||||
created, err := s.repo.create(ctx, p)
|
||||
if err != nil {
|
||||
// If we incremented the stream count but creation failed, decrement it
|
||||
if input.Type == TypeStream {
|
||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, input.NetworkID); decErr != nil {
|
||||
slog.Warn("failed to rollback stream count after particle creation failure", "error", decErr, "network_id", input.NetworkID)
|
||||
}
|
||||
}
|
||||
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
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// If it's an open stream, decrement the count
|
||||
if p.Type == TypeStream && getStreamStatus(p.Data) == string(StreamStatusOpen) {
|
||||
if err := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// Check and increment capacity
|
||||
err = s.networkSvc.IncrementOpenStreamCount(ctx, p.NetworkID)
|
||||
if err != nil {
|
||||
if errors.Is(err, network.ErrCapacityExceeded) {
|
||||
return ErrCapacityExceeded
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Update stream status in data
|
||||
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||
if err != nil {
|
||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
|
||||
slog.Warn("failed to rollback stream count after status update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.repo.update(ctx, id, newData, time.Now())
|
||||
if err != nil {
|
||||
// Rollback the capacity increment
|
||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
|
||||
slog.Warn("failed to rollback stream count after particle update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Decrement capacity
|
||||
return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user