953 lines
32 KiB
Go
953 lines
32 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...)
|
|
case intentstream.NotificationTypeRuntimeImagePullFailed:
|
|
return append([]string(nil), service.adminRouting.RuntimeImagePullFailed...)
|
|
case intentstream.NotificationTypeRuntimeContainerStartFailed:
|
|
return append([]string(nil), service.adminRouting.RuntimeContainerStartFailed...)
|
|
case intentstream.NotificationTypeRuntimeStartConfigInvalid:
|
|
return append([]string(nil), service.adminRouting.RuntimeStartConfigInvalid...)
|
|
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
|
|
}
|