// Package notification implements the in-process notification pipeline // described in `backend/PLAN.md` §5.7, `ARCHITECTURE.md` §12, and the // catalog in `backend/README.md` §10. Producers (lobby, runtime) submit // intents via Submit; the service persists each intent into // `backend.notifications`, materialises one row per (recipient, channel) // in `backend.notification_routes`, and attempts a synchronous best-effort // dispatch. Failed routes are picked up by a background Worker that retries // with exponential backoff and dead-letters past the configured maximum. // // Push routes are emitted via PushPublisher (the canonical // `push.Service` over gRPC; the package also ships a // NoopPushPublisher for tests). Email routes call // mail.EnqueueTemplate, which feeds the durable mail outbox. package notification import ( "time" "galaxy/backend/internal/config" "go.uber.org/zap" ) // Status values stored in `notification_routes.status`. Mirrored by the // CHECK constraint in migration 00001. const ( RouteStatusPending = "pending" RouteStatusRetrying = "retrying" RouteStatusPublished = "published" RouteStatusSkipped = "skipped" RouteStatusDeadLettered = "dead_lettered" ) // Channel values stored in `notification_routes.channel`. The catalog in // `backend/README.md` §10 documents the per-kind set. const ( ChannelPush = "push" ChannelEmail = "email" ) // Backoff parameters for the route worker. Mirrors the trade-off captured // for the mail outbox in `backend/README.md`: exponential // growth from a 10 second base, capped at 10 minutes, with ±25% jitter. const ( backoffBase = 10 * time.Second backoffFactor = 2.0 backoffMax = 10 * time.Minute backoffJitter = 0.25 // claimBatchSize bounds the number of routes pulled out of Postgres // per worker tick. Same logic as `mail.claimBatchSize`: each row is // processed in its own short transaction so a slow channel does not // block its peers. claimBatchSize = 16 ) // Service is the notification entry point. It composes the persistence // store, the push and mail dispatchers, the account resolver used for // recipient email lookups, runtime configuration, and a structured // logger. type Service struct { deps Deps } // NewService constructs a Service from deps. Nil Logger defaults to // zap.NewNop; nil Now defaults to time.Now. Store, Mail, and Accounts // must be non-nil — calling Service methods with either nil panics on // first use, matching how the rest of `internal/*` signals missing // wiring. A nil Push defaults to the no-op publisher used by tests // that do not exercise the gRPC stream. func NewService(deps Deps) *Service { if deps.Now == nil { deps.Now = time.Now } if deps.Logger == nil { deps.Logger = zap.NewNop() } deps.Logger = deps.Logger.Named("notification") if deps.Push == nil { deps.Push = NewNoopPushPublisher(deps.Logger) } return &Service{deps: deps} } // Config returns the runtime notification configuration. Worker uses it // to schedule the scan loop and bound retries. func (s *Service) Config() config.NotificationConfig { return s.deps.Config } // Logger returns the package-named structured logger. Worker and the // admin handlers reuse it so scoped fields stay consistent. func (s *Service) Logger() *zap.Logger { return s.deps.Logger } // now returns the package-configured clock; the helper keeps the rest // of the code free from `if s.deps.Now == nil` checks. func (s *Service) now() time.Time { if s.deps.Now == nil { return time.Now() } return s.deps.Now() } // nowUTC returns the configured clock normalised to UTC, matching the // convention used by `time.Time` columns elsewhere in `backend`. func (s *Service) nowUTC() time.Time { return s.now().UTC() } // adminEmail returns the configured admin recipient address with // surrounding whitespace removed; the empty string indicates no admin // recipient is configured. func (s *Service) adminEmail() string { return trimSpace(s.deps.Config.AdminEmail) }