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:
@@ -16,11 +16,11 @@ type Reader interface {
|
||||
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||
// IsMember returns ErrInvalidHumanId if humanId is empty.
|
||||
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||
// ListAll returns all networks with their members
|
||||
// ListMembers returns an empty slice if the network doesn't exist.
|
||||
ListMembers(ctx context.Context, networkID string) ([]string, error)
|
||||
ListAll(ctx context.Context) ([]*Network, error)
|
||||
// ListAllMemberships returns humanId -> networkIds for every human with at
|
||||
// least one membership. Humans with zero memberships are absent from the map.
|
||||
// Used by the membership reconciler to diff the Firestore mirror.
|
||||
ListAllMemberships(ctx context.Context) (map[string][]string, error)
|
||||
|
||||
CountSeats(ctx context.Context, networkID string) (int, error)
|
||||
@@ -34,8 +34,8 @@ type readerImpl struct {
|
||||
repo repository
|
||||
}
|
||||
|
||||
// newReader returns the concrete reader. Used by NewService to embed without
|
||||
// going through the Reader interface (which would hide pool/repo).
|
||||
// newReader exposes the concrete type so the service can embed it without
|
||||
// hiding pool/repo behind the Reader interface.
|
||||
func newReader(pool *pgxpool.Pool) *readerImpl {
|
||||
return &readerImpl{
|
||||
pool: pool,
|
||||
@@ -69,6 +69,12 @@ func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (b
|
||||
return r.repo.isMember(ctx, networkID, humanId)
|
||||
}
|
||||
|
||||
func (r *readerImpl) ListMembers(ctx context.Context, networkID string) ([]string, error) {
|
||||
// Admin is guaranteed to be in network_members: Create() calls AddMembers
|
||||
// for the admin, and RemoveMemberFromNetwork rejects admin removal.
|
||||
return r.repo.getMemberHumanIds(ctx, networkID)
|
||||
}
|
||||
|
||||
func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) {
|
||||
return r.repo.listAll(ctx)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@ import (
|
||||
"go.jetify.com/typeid"
|
||||
)
|
||||
|
||||
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx.
|
||||
// Used by repository helpers that the service layer may run either standalone
|
||||
// (against the pool) or inside a transaction.
|
||||
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx,
|
||||
// so repository helpers can run standalone or inside a transaction.
|
||||
type dbtx interface {
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
@@ -57,8 +56,7 @@ type repository interface {
|
||||
deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error
|
||||
}
|
||||
|
||||
// networkColumns lists every column selected when hydrating a Network.
|
||||
// Centralized to keep SELECTs and Scan() calls in sync.
|
||||
// Centralized so SELECTs and scanNetwork stay in sync.
|
||||
const networkColumns = `id, name, admin_human_id, created_at`
|
||||
|
||||
func scanNetwork(row pgx.Row, n *Network) error {
|
||||
@@ -283,8 +281,6 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// Invitation methods
|
||||
|
||||
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO network_invitations (network_id, email) VALUES ($1, $2)
|
||||
|
||||
@@ -28,7 +28,7 @@ var ErrInvalidRetentionHours = errors.New("message retention hours must be betwe
|
||||
type Service interface {
|
||||
Reader
|
||||
|
||||
// Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
|
||||
// Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
|
||||
Create(ctx context.Context, name, adminHumanId string) (*Network, error)
|
||||
// SetName returns ErrNotFound or ErrInvalidName.
|
||||
SetName(ctx context.Context, id, name string) error
|
||||
@@ -145,10 +145,8 @@ func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// mirrorAddMembership / mirrorRemoveMembership keep the live store membership
|
||||
// projection (humans/{humanId}.networks) in sync with Postgres. Called after
|
||||
// the Postgres transaction commits. Failures are logged but not returned:
|
||||
// Postgres is the source of truth and the reconciler will heal drift.
|
||||
// Mirror the live store membership projection (humans/{humanId}.networks).
|
||||
// Postgres is the source of truth: failures are logged and the reconciler heals drift.
|
||||
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
|
||||
if err := s.pub.Add(ctx, humanId, networkID); err != nil {
|
||||
slog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID)
|
||||
@@ -161,8 +159,7 @@ func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, netwo
|
||||
}
|
||||
}
|
||||
|
||||
// mutateMembers runs fn in a tx, recounts seats, calls billing.SyncSeats,
|
||||
// and commits. Any error rolls the membership change back.
|
||||
// Runs fn in a tx and syncs seats to billing atomically. Any error rolls back.
|
||||
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package network
|
||||
|
||||
import "strings"
|
||||
|
||||
// ResolveVisibility expands a stream particle's visible_to entries into the set
|
||||
// of human IDs that should see (and thus be notified about) activity in that
|
||||
// stream. Entries are formatted as `human:{id}` for a specific human or
|
||||
// `network:{id}` to expand to every member of the surrounding network.
|
||||
//
|
||||
// networkMembers must contain every human currently in the network (members +
|
||||
// admin). visible_to entries that point to humans no longer in the network are
|
||||
// dropped — they may have been removed since the stream was created.
|
||||
//
|
||||
// Returns a deduped slice; ordering is not stable.
|
||||
func ResolveVisibility(visibleTo []string, networkMembers []string) []string {
|
||||
memberSet := make(map[string]bool, len(networkMembers))
|
||||
for _, id := range networkMembers {
|
||||
memberSet[id] = true
|
||||
}
|
||||
|
||||
result := make(map[string]bool)
|
||||
for _, entry := range visibleTo {
|
||||
switch {
|
||||
case strings.HasPrefix(entry, "human:"):
|
||||
id := strings.TrimPrefix(entry, "human:")
|
||||
if memberSet[id] {
|
||||
result[id] = true
|
||||
}
|
||||
case strings.HasPrefix(entry, "network:"):
|
||||
for id := range memberSet {
|
||||
result[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(result))
|
||||
for id := range result {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user