Files
galaxy-game/notification/internal/service/acceptintent/service.go
T
2026-04-22 08:49:45 +02:00

947 lines
31 KiB
Go

// Package acceptintent implements durable idempotent acceptance of normalized
// notification intents.
package acceptintent
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
netmail "net/mail"
"strings"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/config"
"galaxy/notification/internal/logging"
)
var (
// ErrConflict reports that an idempotency scope already exists for
// different normalized content.
ErrConflict = errors.New("accept intent conflict")
// ErrRecipientNotFound reports that at least one user-targeted recipient
// does not exist in the trusted User Service directory.
ErrRecipientNotFound = errors.New("accept intent recipient not found")
// ErrServiceUnavailable reports that durable acceptance could not be
// completed or recovered safely.
ErrServiceUnavailable = errors.New("accept intent service unavailable")
)
// Outcome identifies the coarse intent-acceptance outcome.
type Outcome string
const (
// OutcomeAccepted reports that the intent was durably accepted into local
// notification state.
OutcomeAccepted Outcome = "accepted"
// OutcomeDuplicate reports that the intent matched already accepted
// normalized content and therefore became a replay no-op.
OutcomeDuplicate Outcome = "duplicate"
)
// RouteStatus identifies one stable notification-route state.
type RouteStatus string
const (
// RouteStatusPending reports that the route is ready for first publication.
RouteStatusPending RouteStatus = "pending"
// RouteStatusPublished reports that the route was durably handed off.
RouteStatusPublished RouteStatus = "published"
// RouteStatusFailed reports that the last publish attempt failed and a
// retry is scheduled.
RouteStatusFailed RouteStatus = "failed"
// RouteStatusDeadLetter reports that the route exhausted its retry budget.
RouteStatusDeadLetter RouteStatus = "dead_letter"
// RouteStatusSkipped reports that the route slot was durably materialized
// but intentionally not emitted.
RouteStatusSkipped RouteStatus = "skipped"
)
// Result stores the coarse outcome of one intent-acceptance attempt.
type Result struct {
// Outcome stores the stable intent-acceptance outcome.
Outcome Outcome
}
// NotificationRecord stores the primary durable notification record accepted
// from one normalized intent.
type NotificationRecord struct {
// NotificationID stores the stable notification identifier.
NotificationID string
// NotificationType stores the frozen notification vocabulary value.
NotificationType intentstream.NotificationType
// Producer stores the frozen producer identifier.
Producer intentstream.Producer
// AudienceKind stores the normalized audience selector.
AudienceKind intentstream.AudienceKind
// RecipientUserIDs stores the normalized recipient user set for
// user-targeted intents.
RecipientUserIDs []string
// PayloadJSON stores the canonical normalized payload JSON string.
PayloadJSON string
// IdempotencyKey stores the producer-owned idempotency key.
IdempotencyKey string
// RequestFingerprint stores the stable normalized request fingerprint.
RequestFingerprint string
// RequestID stores the optional tracing request identifier.
RequestID string
// TraceID stores the optional tracing trace identifier.
TraceID string
// OccurredAt stores when the producer says the event happened.
OccurredAt time.Time
// AcceptedAt stores when Notification Service durably accepted the intent.
AcceptedAt time.Time
// UpdatedAt stores the last notification-record mutation timestamp.
UpdatedAt time.Time
}
// NotificationRoute stores one durable route slot derived from an accepted
// notification.
type NotificationRoute struct {
// NotificationID stores the owning notification identifier.
NotificationID string
// RouteID stores the stable `<channel>:<recipient_ref>` identifier.
RouteID string
// Channel stores the route channel slot.
Channel intentstream.Channel
// RecipientRef stores the stable target slot identifier.
RecipientRef string
// Status stores the current route status.
Status RouteStatus
// AttemptCount stores how many publication attempts already ran.
AttemptCount int
// MaxAttempts stores the total retry budget for Channel.
MaxAttempts int
// NextAttemptAt stores the next scheduled publication time when Status is
// RouteStatusPending or RouteStatusFailed.
NextAttemptAt time.Time
// ResolvedEmail stores the already-known email target when available.
ResolvedEmail string
// ResolvedLocale stores the already-known locale when available.
ResolvedLocale string
// LastErrorClassification stores the optional last classified route error.
LastErrorClassification string
// LastErrorMessage stores the optional last route error message.
LastErrorMessage string
// LastErrorAt stores when the last route error happened.
LastErrorAt time.Time
// CreatedAt stores when the route was materialized.
CreatedAt time.Time
// UpdatedAt stores the last route mutation timestamp.
UpdatedAt time.Time
// PublishedAt stores when the route reached published.
PublishedAt time.Time
// DeadLetteredAt stores when the route reached dead_letter.
DeadLetteredAt time.Time
// SkippedAt stores when the route reached skipped.
SkippedAt time.Time
}
// IdempotencyRecord stores one durable `(producer, idempotency_key)`
// reservation.
type IdempotencyRecord struct {
// Producer stores the owning producer identifier.
Producer intentstream.Producer
// IdempotencyKey stores the producer-owned idempotency key.
IdempotencyKey string
// NotificationID stores the accepted notification identifier.
NotificationID string
// RequestFingerprint stores the stable normalized request fingerprint.
RequestFingerprint string
// CreatedAt stores when the reservation was created.
CreatedAt time.Time
// ExpiresAt stores when the reservation expires.
ExpiresAt time.Time
}
// AcceptInput stores one normalized intent plus its chosen notification
// identifier.
type AcceptInput struct {
// NotificationID stores the stable accepted notification identifier.
NotificationID string
// Intent stores the normalized decoded ingress intent.
Intent intentstream.Intent
}
// CreateAcceptanceInput stores the durable write set required to accept one
// notification intent.
type CreateAcceptanceInput struct {
// Notification stores the accepted notification record.
Notification NotificationRecord
// Routes stores every durable route slot derived from Notification.
Routes []NotificationRoute
// Idempotency stores the idempotency reservation bound to Notification.
Idempotency IdempotencyRecord
}
// Store describes the durable storage required by the intent-acceptance use
// case.
type Store interface {
// CreateAcceptance stores the complete durable write set for one intent
// acceptance attempt. Implementations must wrap ErrConflict when the write
// set races with already accepted state.
CreateAcceptance(context.Context, CreateAcceptanceInput) error
// GetIdempotency loads one existing idempotency reservation.
GetIdempotency(context.Context, intentstream.Producer, string) (IdempotencyRecord, bool, error)
// GetNotification loads one accepted notification by NotificationID.
GetNotification(context.Context, string) (NotificationRecord, bool, error)
}
// UserRecord stores the enrichment data resolved for one recipient user.
type UserRecord struct {
// Email stores the current user email address.
Email string
// PreferredLanguage stores the current user preferred language tag.
PreferredLanguage string
}
// Validate reports whether record contains usable recipient enrichment data.
func (record UserRecord) Validate() error {
if strings.TrimSpace(record.Email) == "" {
return errors.New("user record email must not be empty")
}
if _, err := netmail.ParseAddress(record.Email); err != nil {
return fmt.Errorf("user record email: %w", err)
}
return nil
}
// UserDirectory resolves trusted recipient data from User Service. Missing
// users must wrap ErrRecipientNotFound. Other failures are treated as
// dependency unavailability.
type UserDirectory interface {
// GetUserByID loads one user by stable user identifier.
GetUserByID(context.Context, string) (UserRecord, error)
}
// Telemetry records low-cardinality intent-acceptance and user-enrichment
// outcomes.
type Telemetry interface {
// RecordIntentOutcome records one accepted notification-intent outcome.
RecordIntentOutcome(context.Context, string, string, string, string)
// RecordUserEnrichmentAttempt records one User Service enrichment lookup
// outcome.
RecordUserEnrichmentAttempt(context.Context, string, string)
}
// Clock provides the current wall-clock time.
type Clock interface {
// Now returns the current time.
Now() time.Time
}
type systemClock struct{}
func (systemClock) Now() time.Time {
return time.Now()
}
// Config stores the dependencies and policies used by Service.
type Config struct {
// Store owns the durable accepted state.
Store Store
// UserDirectory resolves recipient email and locale from User Service.
UserDirectory UserDirectory
// Clock provides wall-clock timestamps.
Clock Clock
// Logger writes structured acceptance logs.
Logger *slog.Logger
// Telemetry records low-cardinality acceptance and enrichment outcomes.
Telemetry Telemetry
// PushMaxAttempts stores the retry budget for push routes.
PushMaxAttempts int
// EmailMaxAttempts stores the retry budget for email routes.
EmailMaxAttempts int
// IdempotencyTTL stores how long accepted idempotency scopes remain valid.
IdempotencyTTL time.Duration
// AdminRouting stores the type-specific administrator email lists.
AdminRouting config.AdminRoutingConfig
}
// Service durably accepts normalized notification intents.
type Service struct {
store Store
userDirectory UserDirectory
clock Clock
logger *slog.Logger
telemetry Telemetry
pushMaxAttempts int
emailMaxAttempts int
idempotencyTTL time.Duration
adminRouting config.AdminRoutingConfig
}
// New constructs Service from cfg.
func New(cfg Config) (*Service, error) {
if cfg.Store == nil {
return nil, errors.New("new accept intent service: nil store")
}
if cfg.UserDirectory == nil {
return nil, errors.New("new accept intent service: nil user directory")
}
if cfg.Clock == nil {
cfg.Clock = systemClock{}
}
if cfg.PushMaxAttempts <= 0 {
return nil, errors.New("new accept intent service: push max attempts must be positive")
}
if cfg.EmailMaxAttempts <= 0 {
return nil, errors.New("new accept intent service: email max attempts must be positive")
}
if cfg.IdempotencyTTL <= 0 {
return nil, errors.New("new accept intent service: idempotency ttl must be positive")
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
if err := cfg.AdminRouting.Validate(); err != nil {
return nil, fmt.Errorf("new accept intent service: %w", err)
}
return &Service{
store: cfg.Store,
userDirectory: cfg.UserDirectory,
clock: cfg.Clock,
logger: cfg.Logger.With("component", "accept_intent"),
telemetry: cfg.Telemetry,
pushMaxAttempts: cfg.PushMaxAttempts,
emailMaxAttempts: cfg.EmailMaxAttempts,
idempotencyTTL: cfg.IdempotencyTTL,
adminRouting: cfg.AdminRouting,
}, nil
}
// Execute durably accepts one normalized intent.
func (service *Service) Execute(ctx context.Context, input AcceptInput) (Result, error) {
if ctx == nil {
return Result{}, errors.New("accept intent: nil context")
}
if service == nil {
return Result{}, errors.New("accept intent: nil service")
}
if err := input.Validate(); err != nil {
return Result{}, fmt.Errorf("accept intent: %w", err)
}
fingerprint, err := requestFingerprint(input.Intent)
if err != nil {
return Result{}, fmt.Errorf("accept intent: %w", err)
}
if result, handled, err := service.resolveReplay(ctx, input, fingerprint); handled {
return result, err
}
createInput, result, err := service.buildCreateInput(ctx, input, fingerprint)
if err != nil {
switch {
case errors.Is(err, ErrRecipientNotFound):
return Result{}, err
case errors.Is(err, ErrServiceUnavailable):
return Result{}, err
default:
return Result{}, fmt.Errorf("accept intent: %w", err)
}
}
if err := service.store.CreateAcceptance(ctx, createInput); err != nil {
if !errors.Is(err, ErrConflict) {
return Result{}, fmt.Errorf("%w: create acceptance: %v", ErrServiceUnavailable, err)
}
if replayResult, handled, replayErr := service.resolveReplay(ctx, input, fingerprint); handled {
return replayResult, replayErr
}
return Result{}, fmt.Errorf("%w: create acceptance conflict without replay state", ErrServiceUnavailable)
}
service.recordIntentOutcome(ctx, createInput.Notification, string(result.Outcome))
logArgs := logging.NotificationAttrs(
createInput.Notification.NotificationID,
createInput.Notification.NotificationType,
createInput.Notification.Producer,
createInput.Notification.AudienceKind,
createInput.Notification.IdempotencyKey,
createInput.Notification.RequestID,
createInput.Notification.TraceID,
)
logArgs = append(logArgs,
"route_count", len(createInput.Routes),
"outcome", string(result.Outcome),
)
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
service.logger.Info("notification intent accepted", logArgs...)
return result, nil
}
// Validate reports whether result stores a supported intent-acceptance
// outcome.
func (result Result) Validate() error {
switch result.Outcome {
case OutcomeAccepted, OutcomeDuplicate:
return nil
default:
return fmt.Errorf("accept intent outcome %q is unsupported", result.Outcome)
}
}
// Validate reports whether input contains a usable acceptance request.
func (input AcceptInput) Validate() error {
if strings.TrimSpace(input.NotificationID) == "" {
return errors.New("accept input notification id must not be empty")
}
if err := input.Intent.Validate(); err != nil {
return fmt.Errorf("accept input intent: %w", err)
}
return nil
}
// Validate reports whether record contains a complete notification record.
func (record NotificationRecord) Validate() error {
if strings.TrimSpace(record.NotificationID) == "" {
return errors.New("notification record notification id must not be empty")
}
if !record.NotificationType.IsKnown() {
return fmt.Errorf("notification record type %q is unsupported", record.NotificationType)
}
if !record.Producer.IsKnown() {
return fmt.Errorf("notification record producer %q is unsupported", record.Producer)
}
if !record.AudienceKind.IsKnown() {
return fmt.Errorf("notification record audience kind %q is unsupported", record.AudienceKind)
}
if strings.TrimSpace(record.PayloadJSON) == "" {
return errors.New("notification record payload json must not be empty")
}
if strings.TrimSpace(record.IdempotencyKey) == "" {
return errors.New("notification record idempotency key must not be empty")
}
if strings.TrimSpace(record.RequestFingerprint) == "" {
return errors.New("notification record request fingerprint must not be empty")
}
if err := validateTimestamp("notification record occurred at", record.OccurredAt); err != nil {
return err
}
if err := validateTimestamp("notification record accepted at", record.AcceptedAt); err != nil {
return err
}
if err := validateTimestamp("notification record updated at", record.UpdatedAt); err != nil {
return err
}
if record.AudienceKind == intentstream.AudienceKindUser && len(record.RecipientUserIDs) == 0 {
return errors.New("notification record recipient user ids must not be empty for audience kind user")
}
if record.AudienceKind == intentstream.AudienceKindAdminEmail && len(record.RecipientUserIDs) > 0 {
return errors.New("notification record recipient user ids must be empty for audience kind admin_email")
}
return nil
}
// Validate reports whether route contains a complete route record.
func (route NotificationRoute) Validate() error {
if strings.TrimSpace(route.NotificationID) == "" {
return errors.New("notification route notification id must not be empty")
}
if strings.TrimSpace(route.RouteID) == "" {
return errors.New("notification route route id must not be empty")
}
if !route.Channel.IsKnown() {
return fmt.Errorf("notification route channel %q is unsupported", route.Channel)
}
if strings.TrimSpace(route.RecipientRef) == "" {
return errors.New("notification route recipient ref must not be empty")
}
if !route.Status.IsKnown() {
return fmt.Errorf("notification route status %q is unsupported", route.Status)
}
if route.AttemptCount < 0 {
return errors.New("notification route attempt count must not be negative")
}
if route.MaxAttempts <= 0 {
return errors.New("notification route max attempts must be positive")
}
if err := validateTimestamp("notification route created at", route.CreatedAt); err != nil {
return err
}
if err := validateTimestamp("notification route updated at", route.UpdatedAt); err != nil {
return err
}
switch route.Status {
case RouteStatusPending, RouteStatusFailed:
if err := validateTimestamp("notification route next attempt at", route.NextAttemptAt); err != nil {
return err
}
case RouteStatusSkipped:
if !route.NextAttemptAt.IsZero() {
return errors.New("notification route next attempt at must be zero for skipped routes")
}
if err := validateTimestamp("notification route skipped at", route.SkippedAt); err != nil {
return err
}
}
return nil
}
// IsKnown reports whether status belongs to the frozen route-status surface.
func (status RouteStatus) IsKnown() bool {
switch status {
case RouteStatusPending,
RouteStatusPublished,
RouteStatusFailed,
RouteStatusDeadLetter,
RouteStatusSkipped:
return true
default:
return false
}
}
// Validate reports whether record contains a complete idempotency record.
func (record IdempotencyRecord) Validate() error {
if !record.Producer.IsKnown() {
return fmt.Errorf("idempotency record producer %q is unsupported", record.Producer)
}
if strings.TrimSpace(record.IdempotencyKey) == "" {
return errors.New("idempotency record idempotency key must not be empty")
}
if strings.TrimSpace(record.NotificationID) == "" {
return errors.New("idempotency record notification id must not be empty")
}
if strings.TrimSpace(record.RequestFingerprint) == "" {
return errors.New("idempotency record request fingerprint must not be empty")
}
if err := validateTimestamp("idempotency record created at", record.CreatedAt); err != nil {
return err
}
if err := validateTimestamp("idempotency record expires at", record.ExpiresAt); err != nil {
return err
}
if !record.ExpiresAt.After(record.CreatedAt) {
return errors.New("idempotency record expires at must be after created at")
}
return nil
}
// Validate reports whether input contains a consistent durable write set.
func (input CreateAcceptanceInput) Validate() error {
if err := input.Notification.Validate(); err != nil {
return fmt.Errorf("notification: %w", err)
}
if err := input.Idempotency.Validate(); err != nil {
return fmt.Errorf("idempotency: %w", err)
}
if input.Idempotency.NotificationID != input.Notification.NotificationID {
return errors.New("idempotency notification id must match notification record")
}
if input.Idempotency.Producer != input.Notification.Producer {
return errors.New("idempotency producer must match notification record")
}
if input.Idempotency.IdempotencyKey != input.Notification.IdempotencyKey {
return errors.New("idempotency key must match notification record")
}
if input.Idempotency.RequestFingerprint != input.Notification.RequestFingerprint {
return errors.New("idempotency request fingerprint must match notification record")
}
seenRouteIDs := make(map[string]struct{}, len(input.Routes))
for index, route := range input.Routes {
if err := route.Validate(); err != nil {
return fmt.Errorf("routes[%d]: %w", index, err)
}
if route.NotificationID != input.Notification.NotificationID {
return fmt.Errorf("routes[%d]: notification id must match notification record", index)
}
if _, ok := seenRouteIDs[route.RouteID]; ok {
return fmt.Errorf("routes[%d]: route id %q is duplicated", index, route.RouteID)
}
seenRouteIDs[route.RouteID] = struct{}{}
if input.Notification.AudienceKind == intentstream.AudienceKindUser {
if !strings.HasPrefix(route.RecipientRef, "user:") {
return fmt.Errorf("routes[%d]: recipient ref must use user: prefix for audience kind user", index)
}
if strings.TrimSpace(route.ResolvedEmail) == "" {
return fmt.Errorf("routes[%d]: resolved email must not be empty for audience kind user", index)
}
if strings.TrimSpace(route.ResolvedLocale) == "" {
return fmt.Errorf("routes[%d]: resolved locale must not be empty for audience kind user", index)
}
}
}
return nil
}
func (service *Service) buildCreateInput(ctx context.Context, input AcceptInput, fingerprint string) (CreateAcceptanceInput, Result, error) {
now := service.clock.Now().UTC().Truncate(time.Millisecond)
record := NotificationRecord{
NotificationID: input.NotificationID,
NotificationType: input.Intent.NotificationType,
Producer: input.Intent.Producer,
AudienceKind: input.Intent.AudienceKind,
RecipientUserIDs: append([]string(nil), input.Intent.RecipientUserIDs...),
PayloadJSON: input.Intent.PayloadJSON,
IdempotencyKey: input.Intent.IdempotencyKey,
RequestFingerprint: fingerprint,
RequestID: input.Intent.RequestID,
TraceID: input.Intent.TraceID,
OccurredAt: input.Intent.OccurredAt,
AcceptedAt: now,
UpdatedAt: now,
}
routes, err := service.materializeRoutes(ctx, record, now)
if err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("materialize routes: %w", err)
}
createInput := CreateAcceptanceInput{
Notification: record,
Routes: routes,
Idempotency: IdempotencyRecord{
Producer: record.Producer,
IdempotencyKey: record.IdempotencyKey,
NotificationID: record.NotificationID,
RequestFingerprint: fingerprint,
CreatedAt: now,
ExpiresAt: now.Add(service.idempotencyTTL),
},
}
if err := createInput.Validate(); err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build create acceptance input: %w", err)
}
result := Result{Outcome: OutcomeAccepted}
if err := result.Validate(); err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build acceptance result: %w", err)
}
return createInput, result, nil
}
func (service *Service) materializeRoutes(ctx context.Context, record NotificationRecord, now time.Time) ([]NotificationRoute, error) {
switch record.AudienceKind {
case intentstream.AudienceKindUser:
recipients, err := service.resolveRecipients(ctx, record.NotificationType, record.RecipientUserIDs)
if err != nil {
return nil, err
}
routes := make([]NotificationRoute, 0, len(record.RecipientUserIDs)*2)
for _, userID := range record.RecipientUserIDs {
recipient := recipients[userID]
recipientRef := "user:" + userID
routes = append(routes,
service.newRoute(record, now, intentstream.ChannelPush, recipientRef, recipient.Email, resolveLocale(recipient.PreferredLanguage)),
service.newRoute(record, now, intentstream.ChannelEmail, recipientRef, recipient.Email, resolveLocale(recipient.PreferredLanguage)),
)
}
return routes, nil
case intentstream.AudienceKindAdminEmail:
adminEmails := service.adminEmailsFor(record.NotificationType)
if len(adminEmails) == 0 {
return []NotificationRoute{
service.newSyntheticAdminConfigRoute(record, now),
}, nil
}
routes := make([]NotificationRoute, 0, len(adminEmails)*2)
for _, email := range adminEmails {
recipientRef := "email:" + email
routes = append(routes,
service.newRoute(record, now, intentstream.ChannelPush, recipientRef, email, intentstream.DefaultResolvedLocale()),
service.newRoute(record, now, intentstream.ChannelEmail, recipientRef, email, intentstream.DefaultResolvedLocale()),
)
}
return routes, nil
default:
return nil, fmt.Errorf("unsupported audience kind %q", record.AudienceKind)
}
}
func (service *Service) resolveRecipients(ctx context.Context, notificationType intentstream.NotificationType, userIDs []string) (map[string]UserRecord, error) {
recipients := make(map[string]UserRecord, len(userIDs))
for _, userID := range userIDs {
record, err := service.userDirectory.GetUserByID(ctx, userID)
switch {
case err == nil:
if err := record.Validate(); err != nil {
service.recordUserEnrichmentAttempt(ctx, notificationType, "service_unavailable")
return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrServiceUnavailable, userID, err)
}
service.recordUserEnrichmentAttempt(ctx, notificationType, "success")
recipients[userID] = record
case errors.Is(err, ErrRecipientNotFound):
service.recordUserEnrichmentAttempt(ctx, notificationType, "recipient_not_found")
return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrRecipientNotFound, userID, err)
default:
service.recordUserEnrichmentAttempt(ctx, notificationType, "service_unavailable")
return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrServiceUnavailable, userID, err)
}
}
return recipients, nil
}
func (service *Service) newRoute(
record NotificationRecord,
now time.Time,
channel intentstream.Channel,
recipientRef string,
resolvedEmail string,
resolvedLocale string,
) NotificationRoute {
route := NotificationRoute{
NotificationID: record.NotificationID,
RouteID: string(channel) + ":" + recipientRef,
Channel: channel,
RecipientRef: recipientRef,
AttemptCount: 0,
MaxAttempts: service.maxAttempts(channel),
ResolvedEmail: resolvedEmail,
ResolvedLocale: resolvedLocale,
CreatedAt: now,
UpdatedAt: now,
}
if record.NotificationType.SupportsChannel(record.AudienceKind, channel) {
route.Status = RouteStatusPending
route.NextAttemptAt = now
return route
}
route.Status = RouteStatusSkipped
route.SkippedAt = now
return route
}
func (service *Service) newSyntheticAdminConfigRoute(record NotificationRecord, now time.Time) NotificationRoute {
recipientRef := "config:" + string(record.NotificationType)
return NotificationRoute{
NotificationID: record.NotificationID,
RouteID: string(intentstream.ChannelEmail) + ":" + recipientRef,
Channel: intentstream.ChannelEmail,
RecipientRef: recipientRef,
Status: RouteStatusSkipped,
AttemptCount: 0,
MaxAttempts: service.emailMaxAttempts,
CreatedAt: now,
UpdatedAt: now,
SkippedAt: now,
}
}
func (service *Service) adminEmailsFor(notificationType intentstream.NotificationType) []string {
switch notificationType {
case intentstream.NotificationTypeGeoReviewRecommended:
return append([]string(nil), service.adminRouting.GeoReviewRecommended...)
case intentstream.NotificationTypeGameGenerationFailed:
return append([]string(nil), service.adminRouting.GameGenerationFailed...)
case intentstream.NotificationTypeLobbyRuntimePausedAfterStart:
return append([]string(nil), service.adminRouting.LobbyRuntimePausedAfterStart...)
case intentstream.NotificationTypeLobbyApplicationSubmitted:
return append([]string(nil), service.adminRouting.LobbyApplicationSubmitted...)
default:
return nil
}
}
func (service *Service) maxAttempts(channel intentstream.Channel) int {
switch channel {
case intentstream.ChannelPush:
return service.pushMaxAttempts
case intentstream.ChannelEmail:
return service.emailMaxAttempts
default:
return 0
}
}
func resolveLocale(preferredLanguage string) string {
if preferredLanguage == intentstream.DefaultResolvedLocale() {
return intentstream.DefaultResolvedLocale()
}
return intentstream.DefaultResolvedLocale()
}
func (service *Service) resolveReplay(ctx context.Context, input AcceptInput, fingerprint string) (Result, bool, error) {
record, found, err := service.store.GetIdempotency(ctx, input.Intent.Producer, input.Intent.IdempotencyKey)
if err != nil {
return Result{}, true, fmt.Errorf("%w: load idempotency: %v", ErrServiceUnavailable, err)
}
if !found {
return Result{}, false, nil
}
if record.RequestFingerprint != fingerprint {
return Result{}, true, fmt.Errorf("%w: request conflicts with current state", ErrConflict)
}
notificationRecord, found, err := service.store.GetNotification(ctx, record.NotificationID)
if err != nil {
return Result{}, true, fmt.Errorf("%w: load notification: %v", ErrServiceUnavailable, err)
}
if !found {
return Result{}, true, fmt.Errorf("%w: notification %q is missing for idempotency scope", ErrServiceUnavailable, record.NotificationID)
}
if notificationRecord.NotificationID != record.NotificationID {
return Result{}, true, fmt.Errorf("%w: replay notification id mismatch", ErrServiceUnavailable)
}
result := Result{Outcome: OutcomeDuplicate}
if err := result.Validate(); err != nil {
return Result{}, true, fmt.Errorf("%w: %v", ErrServiceUnavailable, err)
}
service.recordIntentOutcome(ctx, notificationRecord, string(result.Outcome))
logArgs := logging.NotificationAttrs(
notificationRecord.NotificationID,
notificationRecord.NotificationType,
notificationRecord.Producer,
notificationRecord.AudienceKind,
notificationRecord.IdempotencyKey,
notificationRecord.RequestID,
notificationRecord.TraceID,
)
logArgs = append(logArgs,
"outcome", string(result.Outcome),
)
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
service.logger.Info("notification intent replay resolved", logArgs...)
return result, true, nil
}
func requestFingerprint(intent intentstream.Intent) (string, error) {
if err := intent.Validate(); err != nil {
return "", err
}
normalized := struct {
NotificationType intentstream.NotificationType `json:"notification_type"`
AudienceKind intentstream.AudienceKind `json:"audience_kind"`
RecipientUserIDs []string `json:"recipient_user_ids,omitempty"`
PayloadJSON json.RawMessage `json:"payload_json"`
}{
NotificationType: intent.NotificationType,
AudienceKind: intent.AudienceKind,
RecipientUserIDs: append([]string(nil), intent.RecipientUserIDs...),
PayloadJSON: json.RawMessage(intent.PayloadJSON),
}
payload, err := json.Marshal(normalized)
if err != nil {
return "", fmt.Errorf("marshal request fingerprint: %w", err)
}
sum := sha256.Sum256(payload)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
func (service *Service) recordIntentOutcome(ctx context.Context, record NotificationRecord, outcome string) {
if service == nil || service.telemetry == nil || strings.TrimSpace(outcome) == "" {
return
}
service.telemetry.RecordIntentOutcome(
ctx,
string(record.NotificationType),
string(record.Producer),
string(record.AudienceKind),
outcome,
)
}
func (service *Service) recordUserEnrichmentAttempt(ctx context.Context, notificationType intentstream.NotificationType, result string) {
if service == nil || service.telemetry == nil || strings.TrimSpace(result) == "" {
return
}
service.telemetry.RecordUserEnrichmentAttempt(ctx, string(notificationType), result)
}
func validateTimestamp(name string, value time.Time) error {
if value.IsZero() {
return fmt.Errorf("%s must not be zero", name)
}
if !value.Equal(value.UTC()) {
return fmt.Errorf("%s must be UTC", name)
}
if !value.Equal(value.Truncate(time.Millisecond)) {
return fmt.Errorf("%s must use millisecond precision", name)
}
return nil
}