feat: backend service
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user