d262f734f0
* 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
893 lines
22 KiB
Go
893 lines
22 KiB
Go
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
|
|
}
|