Files
galaxy-game/backend/internal/notification/notification.go
T
2026-05-06 10:14:55 +03:00

118 lines
3.9 KiB
Go

// 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)
}