package notification import ( "context" "errors" "fmt" "time" "galaxy/backend/internal/user" "github.com/google/uuid" "go.uber.org/zap" ) // Submit accepts a producer intent, validates it against the catalog, // resolves recipients, materialises route rows, persists everything in // one transaction, and best-effort dispatches the routes synchronously. // // The contract: producers never block on Submit, and Submit never // surfaces a validation failure as an error — malformed intents go to // `notification_malformed_intents` and the call returns nil. Real // errors (encoder failure, Postgres trouble) are wrapped and returned. // // On idempotent re-submit (same kind + idempotency_key) the existing // notification id is honoured and route materialisation is skipped. func (s *Service) Submit(ctx context.Context, intent Intent) (uuid.UUID, error) { entry, ok := LookupCatalog(intent.Kind) if !ok { s.recordMalformed(ctx, intent, ErrUnknownKind.Error()) return uuid.Nil, nil } if trimSpace(intent.IdempotencyKey) == "" { s.recordMalformed(ctx, intent, ErrEmptyIdempotencyKey.Error()) return uuid.Nil, nil } if !entry.Admin && len(intent.Recipients) == 0 { s.recordMalformed(ctx, intent, ErrNoRecipients.Error()) return uuid.Nil, nil } now := s.nowUTC() notificationID := uuid.New() var primaryUserID *uuid.UUID if !entry.Admin && len(intent.Recipients) == 1 { uid := intent.Recipients[0] primaryUserID = &uid } routes, err := s.materialiseRoutes(ctx, notificationID, entry, intent, now) if err != nil { return uuid.Nil, err } storedID, inserted, err := s.deps.Store.InsertNotification(ctx, InsertNotificationArgs{ NotificationID: notificationID, Kind: intent.Kind, IdempotencyKey: intent.IdempotencyKey, UserID: primaryUserID, Payload: intent.Payload, Routes: routes, }) if err != nil { return uuid.Nil, fmt.Errorf("notification submit: %w", err) } if !inserted { s.deps.Logger.Debug("idempotent submit, returning existing notification", zap.String("kind", intent.Kind), zap.String("idempotency_key", intent.IdempotencyKey), zap.String("notification_id", storedID.String()), ) return storedID, nil } // Best-effort synchronous dispatch: any pending route gets a single // attempt right now. Failures stay on the row for the worker to // retry; they are not surfaced to producers. for i := range routes { if routes[i].Status != RouteStatusPending { continue } s.bestEffortDispatch(ctx, Notification{ NotificationID: notificationID, Kind: intent.Kind, IdempotencyKey: intent.IdempotencyKey, UserID: primaryUserID, Payload: intent.Payload, CreatedAt: now, }, routeFromSeed(notificationID, routes[i], now)) } return notificationID, nil } // materialiseRoutes builds the per-(recipient, channel) seeds that // land in `notification_routes`. The function performs recipient // resolution and the catalog-aware channel fan-out. Each seed already // carries its terminal status (`pending` for live routes, `skipped` // for cases where the destination cannot be resolved). func (s *Service) materialiseRoutes(ctx context.Context, notificationID uuid.UUID, entry CatalogEntry, intent Intent, now time.Time) ([]RouteSeed, error) { _ = notificationID maxAttempts := int32(s.deps.Config.MaxAttempts) if maxAttempts <= 0 { maxAttempts = 1 } pendingNext := timePtr(now.UTC()) if entry.Admin { // Admin-channel kinds: one row per channel, no per-user fan-out. seeds := make([]RouteSeed, 0, len(entry.Channels)) for _, ch := range entry.Channels { seed := RouteSeed{ RouteID: uuid.New(), Channel: ch, Status: RouteStatusPending, MaxAttempts: maxAttempts, NextAttemptAt: pendingNext, } if ch == ChannelEmail { seed.ResolvedEmail = s.adminEmail() if seed.ResolvedEmail == "" { seed.Status = RouteStatusSkipped seed.NextAttemptAt = nil seed.SkippedAt = timePtr(now.UTC()) seed.LastError = "BACKEND_NOTIFICATION_ADMIN_EMAIL not configured" s.deps.Logger.Warn("admin notification skipped: admin email not configured", zap.String("kind", intent.Kind), zap.String("idempotency_key", intent.IdempotencyKey), ) } } seeds = append(seeds, seed) } return seeds, nil } // Per-user kinds: fan out across (recipient × channel). seeds := make([]RouteSeed, 0, len(intent.Recipients)*len(entry.Channels)) for _, userID := range intent.Recipients { uid := userID account, err := s.resolveAccount(ctx, userID) for _, ch := range entry.Channels { seed := RouteSeed{ RouteID: uuid.New(), Channel: ch, Status: RouteStatusPending, MaxAttempts: maxAttempts, NextAttemptAt: pendingNext, UserID: &uid, DeviceSessionID: intent.DeviceSessionID, } switch ch { case ChannelEmail: if err != nil { seed.Status = RouteStatusSkipped seed.NextAttemptAt = nil seed.SkippedAt = timePtr(now.UTC()) seed.LastError = err.Error() } else { seed.ResolvedEmail = account.Email seed.ResolvedLocale = account.PreferredLanguage if trimSpace(seed.ResolvedEmail) == "" { seed.Status = RouteStatusSkipped seed.NextAttemptAt = nil seed.SkippedAt = timePtr(now.UTC()) seed.LastError = "recipient has no email on file" } } case ChannelPush: if err != nil { seed.Status = RouteStatusSkipped seed.NextAttemptAt = nil seed.SkippedAt = timePtr(now.UTC()) seed.LastError = err.Error() } else if account.PreferredLanguage != "" { seed.ResolvedLocale = account.PreferredLanguage } } seeds = append(seeds, seed) } } return seeds, nil } // resolveAccount fetches the recipient profile through the configured // AccountResolver. user.ErrAccountNotFound is mapped to a sentinel-free // error string so the route is skipped without a stack-trace log. func (s *Service) resolveAccount(ctx context.Context, userID uuid.UUID) (user.Account, error) { account, err := s.deps.Accounts.GetAccount(ctx, userID) if err != nil { if errors.Is(err, user.ErrAccountNotFound) { return user.Account{}, errors.New("recipient account not found") } return user.Account{}, fmt.Errorf("resolve recipient %s: %w", userID, err) } if account.DeletedAt != nil { return user.Account{}, errors.New("recipient account soft-deleted") } return account, nil } // recordMalformed best-effort persists an invalid intent. Logger is // informational; a Postgres failure here is logged but never bubbles // up to the producer, matching the README §10 contract. func (s *Service) recordMalformed(ctx context.Context, intent Intent, reason string) { payload := map[string]any{ "kind": intent.Kind, "idempotency_key": intent.IdempotencyKey, } if len(intent.Payload) > 0 { payload["payload"] = intent.Payload } if len(intent.Recipients) > 0 { recipients := make([]string, 0, len(intent.Recipients)) for _, r := range intent.Recipients { recipients = append(recipients, r.String()) } payload["recipients"] = recipients } if intent.DeviceSessionID != nil { payload["device_session_id"] = intent.DeviceSessionID.String() } if err := s.deps.Store.InsertMalformed(ctx, payload, reason); err != nil { s.deps.Logger.Warn("failed to persist malformed notification intent", zap.String("kind", intent.Kind), zap.String("reason", reason), zap.Error(err), ) return } s.deps.Logger.Info("notification intent dropped as malformed", zap.String("kind", intent.Kind), zap.String("reason", reason), ) } // routeFromSeed converts a RouteSeed (the pre-insert snapshot the // dispatcher needs) to a Route value the worker / dispatcher exchange // after the row is durably persisted. func routeFromSeed(notificationID uuid.UUID, seed RouteSeed, now time.Time) Route { r := Route{ RouteID: seed.RouteID, NotificationID: notificationID, Channel: seed.Channel, Status: seed.Status, Attempts: 0, MaxAttempts: seed.MaxAttempts, NextAttemptAt: seed.NextAttemptAt, ResolvedEmail: seed.ResolvedEmail, ResolvedLocale: seed.ResolvedLocale, UserID: seed.UserID, DeviceSessionID: seed.DeviceSessionID, CreatedAt: now.UTC(), UpdatedAt: now.UTC(), SkippedAt: seed.SkippedAt, LastError: seed.LastError, } return r }