Mobile notifications for iOS (#210)
* mobile: wire notification registration and listener
* implement backend components for push notifications
* refactor: agentic comment cleanup
* docs: use proper module name for particle processor
* set required env variables for push notifications
* bump version
* fix: always upsert push token on mobile start
* Revert "fix: always upsert push token on mobile start"
This reverts commit 90ff18a788.
* send push notifications regardless of online status
This commit was merged in pull request #210.
This commit is contained in:
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParticleType represents the type of particle
|
||||
type ParticleType string
|
||||
|
||||
const (
|
||||
@@ -20,7 +19,6 @@ const (
|
||||
// TypeThink ParticleType = "think"
|
||||
)
|
||||
|
||||
// VisibilityMode represents how access to a particle is determined
|
||||
type VisibilityMode string
|
||||
|
||||
const (
|
||||
@@ -32,7 +30,6 @@ const (
|
||||
var ErrInvalidParticleType = errors.New("invalid particle type")
|
||||
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
|
||||
|
||||
// ParseParticleType parses a string into a ParticleType
|
||||
func ParseParticleType(s string) (ParticleType, error) {
|
||||
switch s {
|
||||
case string(TypeStream):
|
||||
@@ -54,7 +51,6 @@ func ParseParticleType(s string) (ParticleType, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseVisibilityMode parses a string into a VisibilityMode
|
||||
func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
||||
switch s {
|
||||
case "", string(VisibilityNetworkAll):
|
||||
@@ -68,7 +64,6 @@ func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stream status values
|
||||
type StreamStatus string
|
||||
|
||||
const (
|
||||
@@ -76,7 +71,6 @@ const (
|
||||
StreamStatusClosed StreamStatus = "closed"
|
||||
)
|
||||
|
||||
// Particle represents a content particle in the system
|
||||
type Particle struct {
|
||||
ID string
|
||||
Type ParticleType
|
||||
@@ -89,7 +83,6 @@ type Particle struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateInput represents the input for creating a new particle
|
||||
type CreateInput struct {
|
||||
Type ParticleType
|
||||
NetworkID string
|
||||
@@ -99,18 +92,15 @@ type CreateInput struct {
|
||||
Visibility VisibilityMode
|
||||
}
|
||||
|
||||
// ListFilter represents filtering options for listing particles
|
||||
type ListFilter struct {
|
||||
Types []ParticleType
|
||||
}
|
||||
|
||||
// Cursor represents a pagination cursor for bidirectional pagination
|
||||
type Cursor struct {
|
||||
Position string // particle ID or timestamp
|
||||
Direction string // "before" or "after"
|
||||
}
|
||||
|
||||
// ParticleList represents a paginated list of particles
|
||||
type ParticleList struct {
|
||||
Particles []*Particle
|
||||
HasMore bool
|
||||
@@ -118,57 +108,48 @@ type ParticleList struct {
|
||||
PrevCursor *Cursor
|
||||
}
|
||||
|
||||
// StreamData represents the data stored for stream particles
|
||||
type StreamData struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "open" or "closed"
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
// FolderData represents the data stored for folder particles
|
||||
type FolderData struct {
|
||||
Name string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
// MediaData represents the data stored for media particles
|
||||
type MediaData struct {
|
||||
ObjectID string `json:"object_id"` // reference to storage object
|
||||
ObjectID string `json:"object_id"`
|
||||
MimeType string `json:"mime_type"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
// Caption *string `json:"caption"`
|
||||
}
|
||||
|
||||
// FileData represents the data stored for file particles
|
||||
type FileData struct {
|
||||
ObjectID string `json:"object_id"` // reference to storage object
|
||||
ObjectID string `json:"object_id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"` // in bytes
|
||||
Size int64 `json:"size"` // bytes
|
||||
}
|
||||
|
||||
// TextData represents the data stored for text particles
|
||||
type TextData struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// QuestData represents the data stored for quest particles
|
||||
type QuestData struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Done bool `json:"done"`
|
||||
Status *string `json:"status"`
|
||||
AssignedTo *string `json:"assigned_to,omitempty"` // email
|
||||
DueDate *string `json:"due_date,omitempty"` // ISO date string
|
||||
DueDate *string `json:"due_date,omitempty"` // ISO date
|
||||
}
|
||||
|
||||
// PaperData represents the data stored for paper particles
|
||||
type PaperData struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // markdown
|
||||
}
|
||||
|
||||
// AckInfo represents an acknowledgment record
|
||||
type AckInfo struct {
|
||||
Email string
|
||||
AckedAt time.Time
|
||||
|
||||
@@ -5,6 +5,5 @@ import "context"
|
||||
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
|
||||
|
||||
type NetworkMembershipChecker interface {
|
||||
// IsMember returns true if the humanId is a member of the network.
|
||||
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ type repository interface {
|
||||
getMembers(ctx context.Context, particleID string) ([]string, error)
|
||||
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||
|
||||
// getAncestorChain returns the particle and all its ancestors (for access checks)
|
||||
// getAncestorChain returns the particle followed by its ancestors, in order.
|
||||
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
|
||||
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
|
||||
|
||||
|
||||
@@ -13,45 +13,46 @@ import (
|
||||
|
||||
const defaultPageSize = 50
|
||||
|
||||
// NOTE: this service is deprecated as we use firestore for particle data
|
||||
// Deprecated: particle data now lives in Firestore. The Postgres-backed
|
||||
// service is retained only for legacy paths.
|
||||
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 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 updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
|
||||
// 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 returns particles in a network. Use parentID=nil for root particles.
|
||||
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
|
||||
// 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 opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
||||
// OpenStream 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 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 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 / RemoveMembers operate on custom-visibility streams only.
|
||||
// Both return 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)
|
||||
// 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 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
|
||||
// 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)
|
||||
@@ -69,10 +70,9 @@ func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Serv
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -83,13 +83,12 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
|
||||
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
|
||||
// ancestors[0] is the target; walk up only on inherited.
|
||||
current := ancestors[0]
|
||||
for {
|
||||
switch current.Visibility {
|
||||
@@ -99,7 +98,7 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
|
||||
return s.repo.isMemberOf(ctx, current.ID, email)
|
||||
case VisibilityInherited:
|
||||
if current.ParentID == nil {
|
||||
// inherited at root is invalid state, deny access
|
||||
// inherited-at-root is invalid; deny.
|
||||
return false, nil
|
||||
}
|
||||
parent, ok := byID[*current.ParentID]
|
||||
@@ -119,30 +118,24 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
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
|
||||
// MVP visibility: children always inherit; roots cannot inherit and
|
||||
// default to network_all. Streams/folders are root-only.
|
||||
if input.ParentID != nil {
|
||||
// Children always inherit from parent
|
||||
input.Visibility = VisibilityInherited
|
||||
input.Members = nil // no members on inherited particles
|
||||
input.Members = nil
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -151,7 +144,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
}
|
||||
}
|
||||
|
||||
// Custom visibility requires at least one member and must be a stream
|
||||
var customMembers []string
|
||||
if input.Visibility == VisibilityCustom {
|
||||
if input.Type != TypeStream {
|
||||
@@ -161,7 +153,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
return nil, ErrMembersRequired
|
||||
}
|
||||
|
||||
// Validate every supplied member against the network checker before touching the DB.
|
||||
// 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}
|
||||
@@ -186,8 +178,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
}
|
||||
}
|
||||
|
||||
// Network membership is verified by handler - we only check particle visibility
|
||||
// If parent specified, check parent access (visibility-based)
|
||||
// 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 {
|
||||
@@ -201,7 +192,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
}
|
||||
}
|
||||
|
||||
// Build the particle
|
||||
p := &Particle{
|
||||
Type: input.Type,
|
||||
NetworkID: input.NetworkID,
|
||||
@@ -215,9 +205,8 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
p.Data = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
// For streams, set initial status to open and check capacity
|
||||
// New streams default to open.
|
||||
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
|
||||
@@ -225,13 +214,11 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
p.Data = data
|
||||
}
|
||||
|
||||
// Create the particle
|
||||
created, err := s.repo.create(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add the pre-validated member list for custom visibility.
|
||||
if len(customMembers) > 0 {
|
||||
if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil {
|
||||
return nil, err
|
||||
@@ -247,7 +234,6 @@ func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -275,7 +261,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -287,7 +272,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
||||
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) {
|
||||
@@ -296,7 +280,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate data matches type requirements
|
||||
if err := validateParticleData(p.Type, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -318,7 +301,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -330,7 +312,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
||||
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) {
|
||||
@@ -352,8 +333,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Network membership is verified by handler - we only check particle visibility
|
||||
// If parentID specified, check access to parent (visibility-based)
|
||||
// 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 {
|
||||
@@ -367,8 +347,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch one extra to determine if there are more
|
||||
// Access filtering is done in the query itself (network_all OR user is member)
|
||||
// Fetch limit+1 to detect a next page; visibility filtering lives in the query.
|
||||
if limit == 0 {
|
||||
limit = defaultPageSize
|
||||
}
|
||||
@@ -415,7 +394,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -427,7 +405,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -444,7 +421,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
||||
return ErrStreamAlreadyOpen
|
||||
}
|
||||
|
||||
// Update stream status in data
|
||||
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -467,7 +443,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -479,7 +454,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -496,7 +470,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
||||
return ErrStreamAlreadyClosed
|
||||
}
|
||||
|
||||
// Update stream status in data
|
||||
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -519,7 +492,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -531,7 +503,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check constraints
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -540,12 +511,11 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
||||
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
|
||||
// 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 {
|
||||
@@ -563,7 +533,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
||||
return err
|
||||
}
|
||||
|
||||
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
|
||||
// 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 {
|
||||
@@ -600,7 +570,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -612,7 +581,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
||||
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) {
|
||||
@@ -621,14 +589,11 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
||||
return err
|
||||
}
|
||||
|
||||
// Only streams can have members
|
||||
if p.Type != TypeStream {
|
||||
return ErrNotAContainer
|
||||
}
|
||||
|
||||
// Validate and normalize emails, checking network membership upfront.
|
||||
// Strict: a normalize failure, checker error, or non-member aborts the
|
||||
// whole operation before any rows are written.
|
||||
// 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 {
|
||||
@@ -664,7 +629,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -676,7 +640,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check type
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -685,7 +648,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
||||
return err
|
||||
}
|
||||
|
||||
// Only streams can have members
|
||||
if p.Type != TypeStream {
|
||||
return ErrNotAContainer
|
||||
}
|
||||
@@ -712,7 +674,6 @@ func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) e
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -733,17 +694,17 @@ func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requester
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access for each particle and mark seen
|
||||
// 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 // Skip non-existent particles
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
continue // Skip inaccessible particles
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||
@@ -760,7 +721,6 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -772,7 +732,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Ack also marks as seen
|
||||
// Ack implies seen.
|
||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -815,7 +775,6 @@ func isValidParticleType(t ParticleType) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -824,7 +783,6 @@ func getStreamStatus(data json.RawMessage) string {
|
||||
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 {
|
||||
@@ -834,10 +792,9 @@ func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, erro
|
||||
return json.Marshal(d)
|
||||
}
|
||||
|
||||
// validateParticleData validates that the data field contains valid JSON
|
||||
// and has required fields for the given particle type.
|
||||
// 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 {
|
||||
// Empty or null data is allowed - will default to {}
|
||||
if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user