feat: notification service
This commit is contained in:
@@ -0,0 +1,946 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
package acceptintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceAcceptsIntentAndMaterializesUserRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
|
||||
"user-2": {Email: "two@example.com", PreferredLanguage: "en-US"},
|
||||
})
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-2", "user-1"}, "request-123", "trace-123", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
|
||||
createInput := store.createInputs[0]
|
||||
require.Equal(t, "1775121700000-0", createInput.Notification.NotificationID)
|
||||
require.Equal(t, []string{"user-1", "user-2"}, createInput.Notification.RecipientUserIDs)
|
||||
require.Equal(t, `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, createInput.Notification.PayloadJSON)
|
||||
require.Len(t, createInput.Routes, 4)
|
||||
pushUser1 := routeByID(t, createInput.Routes, "push:user:user-1")
|
||||
emailUser1 := routeByID(t, createInput.Routes, "email:user:user-1")
|
||||
pushUser2 := routeByID(t, createInput.Routes, "push:user:user-2")
|
||||
emailUser2 := routeByID(t, createInput.Routes, "email:user:user-2")
|
||||
require.Equal(t, RouteStatusPending, pushUser1.Status)
|
||||
require.Equal(t, 3, pushUser1.MaxAttempts)
|
||||
require.Equal(t, "one@example.com", pushUser1.ResolvedEmail)
|
||||
require.Equal(t, "en", pushUser1.ResolvedLocale)
|
||||
require.Equal(t, RouteStatusPending, emailUser1.Status)
|
||||
require.Equal(t, 7, emailUser1.MaxAttempts)
|
||||
require.Equal(t, "one@example.com", emailUser1.ResolvedEmail)
|
||||
require.Equal(t, "en", emailUser1.ResolvedLocale)
|
||||
require.Equal(t, "two@example.com", pushUser2.ResolvedEmail)
|
||||
require.Equal(t, "en", pushUser2.ResolvedLocale)
|
||||
require.Equal(t, "two@example.com", emailUser2.ResolvedEmail)
|
||||
require.Equal(t, "en", emailUser2.ResolvedLocale)
|
||||
require.Equal(t, []string{"user-1", "user-2"}, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceTreatsEquivalentReplayAsDuplicate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
|
||||
})
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
firstInput := AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "request-1", "trace-1", time.UnixMilli(1775121700001).UTC()),
|
||||
}
|
||||
secondInput := AcceptInput{
|
||||
NotificationID: "1775121700001-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "request-2", "trace-2", time.UnixMilli(1775121799999).UTC()),
|
||||
}
|
||||
|
||||
firstResult, err := service.Execute(context.Background(), firstInput)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, firstResult.Outcome)
|
||||
|
||||
secondResult, err := service.Execute(context.Background(), secondInput)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeDuplicate, secondResult.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Equal(t, []string{"user-1"}, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceRejectsConflictOnSameIdempotencyScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
|
||||
})
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700002-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":55}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700002).UTC()),
|
||||
})
|
||||
require.ErrorIs(t, err, ErrConflict)
|
||||
}
|
||||
|
||||
func TestServiceMaterializesPublicLobbyApplicationAdminRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(nil)
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
AdminRouting: config.AdminRoutingConfig{
|
||||
LobbyApplicationSubmitted: []string{"owner@example.com"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validPublicApplicationIntent(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Len(t, store.createInputs[0].Routes, 2)
|
||||
|
||||
pushRoute := routeByID(t, store.createInputs[0].Routes, "push:email:owner@example.com")
|
||||
emailRoute := routeByID(t, store.createInputs[0].Routes, "email:email:owner@example.com")
|
||||
|
||||
require.Equal(t, RouteStatusSkipped, pushRoute.Status)
|
||||
require.Equal(t, "owner@example.com", pushRoute.ResolvedEmail)
|
||||
require.Equal(t, "en", pushRoute.ResolvedLocale)
|
||||
require.Equal(t, RouteStatusPending, emailRoute.Status)
|
||||
require.Equal(t, "owner@example.com", emailRoute.ResolvedEmail)
|
||||
require.Equal(t, "en", emailRoute.ResolvedLocale)
|
||||
require.Empty(t, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceMaterializesSyntheticAdminConfigRouteWhenListIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(nil)
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validPublicApplicationIntent(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Len(t, store.createInputs[0].Routes, 1)
|
||||
|
||||
route := store.createInputs[0].Routes[0]
|
||||
require.Equal(t, "email:config:lobby.application.submitted", route.RouteID)
|
||||
require.Equal(t, RouteStatusSkipped, route.Status)
|
||||
require.Equal(t, 7, route.MaxAttempts)
|
||||
require.True(t, route.NextAttemptAt.IsZero())
|
||||
require.Empty(t, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceMaterializesChannelMatrixAndRetryBudgets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
tests := []struct {
|
||||
name string
|
||||
intent intentstream.Intent
|
||||
adminRouting config.AdminRoutingConfig
|
||||
wantRoutes map[string]struct {
|
||||
status RouteStatus
|
||||
maxAttempts int
|
||||
}
|
||||
}{
|
||||
{
|
||||
name: "user push and email",
|
||||
intent: validTurnReadyIntent(
|
||||
`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
[]string{"user-1"},
|
||||
"",
|
||||
"",
|
||||
now,
|
||||
),
|
||||
wantRoutes: map[string]struct {
|
||||
status RouteStatus
|
||||
maxAttempts int
|
||||
}{
|
||||
"push:user:user-1": {status: RouteStatusPending, maxAttempts: 3},
|
||||
"email:user:user-1": {status: RouteStatusPending, maxAttempts: 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user email only",
|
||||
intent: intentstream.Intent{
|
||||
NotificationType: intentstream.NotificationTypeLobbyInviteExpired,
|
||||
Producer: intentstream.ProducerGameLobby,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
IdempotencyKey: "game-123:invite-expired",
|
||||
OccurredAt: now,
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"}`,
|
||||
},
|
||||
wantRoutes: map[string]struct {
|
||||
status RouteStatus
|
||||
maxAttempts int
|
||||
}{
|
||||
"push:user:user-1": {status: RouteStatusSkipped, maxAttempts: 3},
|
||||
"email:user:user-1": {status: RouteStatusPending, maxAttempts: 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "admin email only",
|
||||
intent: intentstream.Intent{
|
||||
NotificationType: intentstream.NotificationTypeGeoReviewRecommended,
|
||||
Producer: intentstream.ProducerGeoProfile,
|
||||
AudienceKind: intentstream.AudienceKindAdminEmail,
|
||||
IdempotencyKey: "geo:user-1",
|
||||
OccurredAt: now,
|
||||
PayloadJSON: `{"observed_country":"DE","review_reason":"country_mismatch","usual_connection_country":"PL","user_email":"pilot@example.com","user_id":"user-1"}`,
|
||||
},
|
||||
adminRouting: config.AdminRoutingConfig{
|
||||
GeoReviewRecommended: []string{"admin@example.com"},
|
||||
},
|
||||
wantRoutes: map[string]struct {
|
||||
status RouteStatus
|
||||
maxAttempts int
|
||||
}{
|
||||
"push:email:admin@example.com": {status: RouteStatusSkipped, maxAttempts: 3},
|
||||
"email:email:admin@example.com": {status: RouteStatusPending, maxAttempts: 7},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "pilot@example.com", PreferredLanguage: "fr-FR"},
|
||||
})
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: now},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
AdminRouting: tt.adminRouting,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: tt.intent,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Len(t, store.createInputs[0].Routes, len(tt.wantRoutes))
|
||||
|
||||
for routeID, want := range tt.wantRoutes {
|
||||
route := routeByID(t, store.createInputs[0].Routes, routeID)
|
||||
require.Equal(t, want.status, route.Status)
|
||||
require.Equal(t, want.maxAttempts, route.MaxAttempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceReturnsRecipientNotFoundForMissingUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(nil)
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-missing"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.ErrorIs(t, err, ErrRecipientNotFound)
|
||||
require.Empty(t, store.createInputs)
|
||||
require.Equal(t, []string{"user-missing"}, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceReturnsServiceUnavailableWhenDirectoryFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(nil)
|
||||
directory.errByUserID["user-1"] = errors.New("user service unavailable")
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
require.Empty(t, store.createInputs)
|
||||
}
|
||||
|
||||
func TestServiceRecordsIntentAndUserEnrichmentTelemetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
|
||||
})
|
||||
telemetry := &recordingTelemetry{}
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
Telemetry: telemetry,
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
}
|
||||
result, err := service.Execute(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
|
||||
duplicateInput := input
|
||||
duplicateInput.NotificationID = "1775121700001-0"
|
||||
result, err = service.Execute(context.Background(), duplicateInput)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeDuplicate, result.Outcome)
|
||||
|
||||
require.Equal(t, []intentOutcomeRecord{
|
||||
{
|
||||
notificationType: "game.turn.ready",
|
||||
producer: "game_master",
|
||||
audienceKind: "user",
|
||||
outcome: "accepted",
|
||||
},
|
||||
{
|
||||
notificationType: "game.turn.ready",
|
||||
producer: "game_master",
|
||||
audienceKind: "user",
|
||||
outcome: "duplicate",
|
||||
},
|
||||
}, telemetry.intentOutcomes)
|
||||
require.Equal(t, []userEnrichmentRecord{
|
||||
{notificationType: "game.turn.ready", result: "success"},
|
||||
}, telemetry.userEnrichment)
|
||||
}
|
||||
|
||||
func TestServiceRecordsUserEnrichmentFailureTelemetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
directory *staticUserDirectory
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "recipient not found",
|
||||
directory: newStaticUserDirectory(nil),
|
||||
want: "recipient_not_found",
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
directory: func() *staticUserDirectory {
|
||||
directory := newStaticUserDirectory(nil)
|
||||
directory.errByUserID["user-1"] = errors.New("user service unavailable")
|
||||
return directory
|
||||
}(),
|
||||
want: "service_unavailable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
telemetry := &recordingTelemetry{}
|
||||
service, err := New(Config{
|
||||
Store: newRecordingStore(),
|
||||
UserDirectory: tt.directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
Telemetry: telemetry,
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, []userEnrichmentRecord{
|
||||
{notificationType: "game.turn.ready", result: tt.want},
|
||||
}, telemetry.userEnrichment)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type recordingStore struct {
|
||||
createInputs []CreateAcceptanceInput
|
||||
idempotency map[string]IdempotencyRecord
|
||||
notifications map[string]NotificationRecord
|
||||
}
|
||||
|
||||
func newRecordingStore() *recordingStore {
|
||||
return &recordingStore{
|
||||
idempotency: make(map[string]IdempotencyRecord),
|
||||
notifications: make(map[string]NotificationRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *recordingStore) CreateAcceptance(_ context.Context, input CreateAcceptanceInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := string(input.Idempotency.Producer) + ":" + input.Idempotency.IdempotencyKey
|
||||
if _, ok := store.idempotency[key]; ok {
|
||||
return ErrConflict
|
||||
}
|
||||
|
||||
store.createInputs = append(store.createInputs, input)
|
||||
store.idempotency[key] = input.Idempotency
|
||||
store.notifications[input.Notification.NotificationID] = input.Notification
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *recordingStore) GetIdempotency(_ context.Context, producer intentstream.Producer, idempotencyKey string) (IdempotencyRecord, bool, error) {
|
||||
record, ok := store.idempotency[string(producer)+":"+idempotencyKey]
|
||||
return record, ok, nil
|
||||
}
|
||||
|
||||
func (store *recordingStore) GetNotification(_ context.Context, notificationID string) (NotificationRecord, bool, error) {
|
||||
record, ok := store.notifications[notificationID]
|
||||
return record, ok, nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
func validTurnReadyIntent(payload string, recipients []string, requestID string, traceID string, occurredAt time.Time) intentstream.Intent {
|
||||
sorted := append([]string(nil), recipients...)
|
||||
if len(sorted) == 2 && sorted[0] == "user-2" {
|
||||
sorted[0], sorted[1] = sorted[1], sorted[0]
|
||||
}
|
||||
return intentstream.Intent{
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: sorted,
|
||||
IdempotencyKey: "game-123:turn-54",
|
||||
OccurredAt: occurredAt.UTC().Truncate(time.Millisecond),
|
||||
RequestID: requestID,
|
||||
TraceID: traceID,
|
||||
PayloadJSON: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func validPublicApplicationIntent() intentstream.Intent {
|
||||
return intentstream.Intent{
|
||||
NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
|
||||
Producer: intentstream.ProducerGameLobby,
|
||||
AudienceKind: intentstream.AudienceKindAdminEmail,
|
||||
IdempotencyKey: "game-456:application-submitted:user-42",
|
||||
OccurredAt: time.UnixMilli(1775121700002).UTC(),
|
||||
PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`,
|
||||
}
|
||||
}
|
||||
|
||||
func routeByID(t *testing.T, routes []NotificationRoute, routeID string) NotificationRoute {
|
||||
t.Helper()
|
||||
|
||||
for _, route := range routes {
|
||||
if route.RouteID == routeID {
|
||||
return route
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("route %q not found", routeID)
|
||||
return NotificationRoute{}
|
||||
}
|
||||
|
||||
type staticUserDirectory struct {
|
||||
records map[string]UserRecord
|
||||
errByUserID map[string]error
|
||||
lookups []string
|
||||
}
|
||||
|
||||
func newStaticUserDirectory(records map[string]UserRecord) *staticUserDirectory {
|
||||
return &staticUserDirectory{
|
||||
records: records,
|
||||
errByUserID: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (directory *staticUserDirectory) GetUserByID(_ context.Context, userID string) (UserRecord, error) {
|
||||
directory.lookups = append(directory.lookups, userID)
|
||||
if err, ok := directory.errByUserID[userID]; ok {
|
||||
return UserRecord{}, err
|
||||
}
|
||||
record, ok := directory.records[userID]
|
||||
if !ok {
|
||||
return UserRecord{}, ErrRecipientNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
type recordingTelemetry struct {
|
||||
intentOutcomes []intentOutcomeRecord
|
||||
userEnrichment []userEnrichmentRecord
|
||||
}
|
||||
|
||||
func (telemetry *recordingTelemetry) RecordIntentOutcome(_ context.Context, notificationType string, producer string, audienceKind string, outcome string) {
|
||||
telemetry.intentOutcomes = append(telemetry.intentOutcomes, intentOutcomeRecord{
|
||||
notificationType: notificationType,
|
||||
producer: producer,
|
||||
audienceKind: audienceKind,
|
||||
outcome: outcome,
|
||||
})
|
||||
}
|
||||
|
||||
func (telemetry *recordingTelemetry) RecordUserEnrichmentAttempt(_ context.Context, notificationType string, result string) {
|
||||
telemetry.userEnrichment = append(telemetry.userEnrichment, userEnrichmentRecord{
|
||||
notificationType: notificationType,
|
||||
result: result,
|
||||
})
|
||||
}
|
||||
|
||||
type intentOutcomeRecord struct {
|
||||
notificationType string
|
||||
producer string
|
||||
audienceKind string
|
||||
outcome string
|
||||
}
|
||||
|
||||
type userEnrichmentRecord struct {
|
||||
notificationType string
|
||||
result string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package service reserves the application-service namespace of Notification
|
||||
// Service.
|
||||
package service
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package malformedintent defines the operator-visible record used for
|
||||
// malformed notification intents.
|
||||
package malformedintent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FailureCode identifies one stable malformed-intent rejection reason.
|
||||
type FailureCode string
|
||||
|
||||
const (
|
||||
// FailureCodeInvalidIntent reports malformed top-level intent fields or an
|
||||
// invalid normalized envelope.
|
||||
FailureCodeInvalidIntent FailureCode = "invalid_intent"
|
||||
|
||||
// FailureCodeInvalidPayload reports malformed or schema-invalid
|
||||
// `payload_json`.
|
||||
FailureCodeInvalidPayload FailureCode = "invalid_payload"
|
||||
|
||||
// FailureCodeIdempotencyConflict reports a duplicate idempotency scope that
|
||||
// conflicts with already accepted normalized content.
|
||||
FailureCodeIdempotencyConflict FailureCode = "idempotency_conflict"
|
||||
|
||||
// FailureCodeRecipientNotFound reports that a user-targeted recipient user
|
||||
// id could not be resolved through User Service.
|
||||
FailureCodeRecipientNotFound FailureCode = "recipient_not_found"
|
||||
)
|
||||
|
||||
// Entry stores one operator-visible malformed notification-intent record.
|
||||
type Entry struct {
|
||||
// StreamEntryID stores the Redis Stream entry identifier of the rejected
|
||||
// intent.
|
||||
StreamEntryID string
|
||||
|
||||
// NotificationType stores the optional raw notification type extracted from
|
||||
// the rejected entry.
|
||||
NotificationType string
|
||||
|
||||
// Producer stores the optional raw producer value extracted from the
|
||||
// rejected entry.
|
||||
Producer string
|
||||
|
||||
// IdempotencyKey stores the optional raw idempotency key extracted from the
|
||||
// rejected entry.
|
||||
IdempotencyKey string
|
||||
|
||||
// FailureCode stores the stable rejection classification.
|
||||
FailureCode FailureCode
|
||||
|
||||
// FailureMessage stores the detailed validation or decode failure.
|
||||
FailureMessage string
|
||||
|
||||
// RawFields stores the raw top-level stream fields captured for operator
|
||||
// inspection.
|
||||
RawFields map[string]any
|
||||
|
||||
// RecordedAt stores when the malformed intent was durably recorded.
|
||||
RecordedAt time.Time
|
||||
}
|
||||
|
||||
// IsKnown reports whether code belongs to the frozen malformed-intent
|
||||
// rejection surface.
|
||||
func (code FailureCode) IsKnown() bool {
|
||||
switch code {
|
||||
case FailureCodeInvalidIntent, FailureCodeInvalidPayload, FailureCodeIdempotencyConflict, FailureCodeRecipientNotFound:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reports whether entry contains a complete malformed-intent record.
|
||||
func (entry Entry) Validate() error {
|
||||
if strings.TrimSpace(entry.StreamEntryID) == "" {
|
||||
return fmt.Errorf("malformed intent stream entry id must not be empty")
|
||||
}
|
||||
if !entry.FailureCode.IsKnown() {
|
||||
return fmt.Errorf("malformed intent failure code %q is unsupported", entry.FailureCode)
|
||||
}
|
||||
if strings.TrimSpace(entry.FailureMessage) == "" {
|
||||
return fmt.Errorf("malformed intent failure message must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(entry.FailureMessage) != entry.FailureMessage {
|
||||
return fmt.Errorf("malformed intent failure message must not contain surrounding whitespace")
|
||||
}
|
||||
if entry.RawFields == nil {
|
||||
return fmt.Errorf("malformed intent raw fields must not be nil")
|
||||
}
|
||||
if err := validateJSONObject("malformed intent raw fields", entry.RawFields); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTimestamp("malformed intent recorded at", entry.RecordedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONObject(name string, value map[string]any) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if string(payload) == "null" {
|
||||
return fmt.Errorf("%s must encode as a JSON object", name)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if decoded == nil {
|
||||
return fmt.Errorf("%s must encode as a JSON object", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package publishmail encodes accepted email routes into Mail Service generic
|
||||
// asynchronous template commands.
|
||||
package publishmail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
netmail "net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
)
|
||||
|
||||
const (
|
||||
commandSourceNotification = "notification"
|
||||
commandPayloadModeTemplate = "template"
|
||||
)
|
||||
|
||||
// Command stores one Mail Service-compatible template delivery command
|
||||
// produced from a durable notification email route.
|
||||
type Command struct {
|
||||
// DeliveryID stores the stable route-level delivery identifier.
|
||||
DeliveryID string
|
||||
|
||||
// IdempotencyKey stores the stable Mail Service deduplication key.
|
||||
IdempotencyKey string
|
||||
|
||||
// RequestedAt stores when Notification Service durably accepted the
|
||||
// notification intent.
|
||||
RequestedAt time.Time
|
||||
|
||||
// PayloadJSON stores the fully encoded template-mode command payload.
|
||||
PayloadJSON string
|
||||
|
||||
// RequestID stores the optional correlation identifier.
|
||||
RequestID string
|
||||
|
||||
// TraceID stores the optional tracing correlation identifier.
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// Values returns the Redis Stream fields appended to the Mail Service command
|
||||
// stream for Command.
|
||||
func (command Command) Values() map[string]any {
|
||||
values := map[string]any{
|
||||
"delivery_id": command.DeliveryID,
|
||||
"source": commandSourceNotification,
|
||||
"payload_mode": commandPayloadModeTemplate,
|
||||
"idempotency_key": command.IdempotencyKey,
|
||||
"requested_at_ms": strconv.FormatInt(command.RequestedAt.UTC().UnixMilli(), 10),
|
||||
"payload_json": command.PayloadJSON,
|
||||
}
|
||||
if command.RequestID != "" {
|
||||
values["request_id"] = command.RequestID
|
||||
}
|
||||
if command.TraceID != "" {
|
||||
values["trace_id"] = command.TraceID
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// Encoder converts one accepted notification record plus its email route into
|
||||
// one Mail Service-compatible generic template command.
|
||||
type Encoder struct{}
|
||||
|
||||
// Encode converts notification plus route into one template delivery command.
|
||||
func (Encoder) Encode(notification acceptintent.NotificationRecord, route acceptintent.NotificationRoute) (Command, error) {
|
||||
if err := notification.Validate(); err != nil {
|
||||
return Command{}, fmt.Errorf("encode mail command: %w", err)
|
||||
}
|
||||
if err := route.Validate(); err != nil {
|
||||
return Command{}, fmt.Errorf("encode mail command: %w", err)
|
||||
}
|
||||
if notification.NotificationID != route.NotificationID {
|
||||
return Command{}, fmt.Errorf("encode mail command: notification id %q does not match route notification id %q", notification.NotificationID, route.NotificationID)
|
||||
}
|
||||
if route.Channel != intentstream.ChannelEmail {
|
||||
return Command{}, fmt.Errorf("encode mail command: route channel %q is unsupported", route.Channel)
|
||||
}
|
||||
if !notification.NotificationType.SupportsChannel(notification.AudienceKind, intentstream.ChannelEmail) {
|
||||
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: notification type %q does not support email", notification.NotificationType)
|
||||
}
|
||||
|
||||
recipientEmail, err := normalizedRecipientEmail(route.ResolvedEmail)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err)
|
||||
}
|
||||
locale, err := normalizedLocale(route.ResolvedLocale)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err)
|
||||
}
|
||||
variables, err := payloadVariables(notification.PayloadJSON)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err)
|
||||
}
|
||||
|
||||
payloadJSON, err := json.Marshal(templatePayloadJSON{
|
||||
To: []string{recipientEmail},
|
||||
Cc: []string{},
|
||||
Bcc: []string{},
|
||||
ReplyTo: []string{},
|
||||
TemplateID: string(notification.NotificationType),
|
||||
Locale: locale,
|
||||
Variables: variables,
|
||||
Attachments: []templateAttachmentJSON{},
|
||||
})
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: marshal payload_json: %w", err)
|
||||
}
|
||||
|
||||
return Command{
|
||||
DeliveryID: notification.NotificationID + "/" + route.RouteID,
|
||||
IdempotencyKey: "notification:" + notification.NotificationID + "/" + route.RouteID,
|
||||
RequestedAt: notification.AcceptedAt,
|
||||
PayloadJSON: string(payloadJSON),
|
||||
RequestID: notification.RequestID,
|
||||
TraceID: notification.TraceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type templatePayloadJSON struct {
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
TemplateID string `json:"template_id"`
|
||||
Locale string `json:"locale"`
|
||||
Variables json.RawMessage `json:"variables"`
|
||||
Attachments []templateAttachmentJSON `json:"attachments"`
|
||||
}
|
||||
|
||||
type templateAttachmentJSON struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
ContentBase64 string `json:"content_base64"`
|
||||
}
|
||||
|
||||
func normalizedRecipientEmail(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf("resolved email must not be empty")
|
||||
}
|
||||
parsed, err := netmail.ParseAddress(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolved email %q must be valid: %w", value, err)
|
||||
}
|
||||
if parsed.Name != "" || parsed.Address != value {
|
||||
return "", fmt.Errorf("resolved email %q must not include a display name", value)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizedLocale(value string) (string, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return "", fmt.Errorf("resolved locale must not be empty")
|
||||
case strings.TrimSpace(value) != value:
|
||||
return "", fmt.Errorf("resolved locale %q must not contain surrounding whitespace", value)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func payloadVariables(payloadJSON string) (json.RawMessage, error) {
|
||||
var payloadObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(payloadJSON), &payloadObject); err != nil {
|
||||
return nil, fmt.Errorf("decode payload_json: %w", err)
|
||||
}
|
||||
if payloadObject == nil {
|
||||
return nil, fmt.Errorf("payload_json must be a JSON object")
|
||||
}
|
||||
|
||||
return json.RawMessage(payloadJSON), nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package publishmail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncoderEncodesUserAndAdminEmailCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
tests := []struct {
|
||||
name string
|
||||
notification acceptintent.NotificationRecord
|
||||
route acceptintent.NotificationRoute
|
||||
wantDeliveryID string
|
||||
wantIdempotency string
|
||||
wantPayloadJSON string
|
||||
}{
|
||||
{
|
||||
name: "user route",
|
||||
notification: acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
IdempotencyKey: "game-123:turn-54",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
route: acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "email:user:user-1",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
wantDeliveryID: "1775121700000-0/email:user:user-1",
|
||||
wantIdempotency: "notification:1775121700000-0/email:user:user-1",
|
||||
wantPayloadJSON: `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54},"attachments":[]}`,
|
||||
},
|
||||
{
|
||||
name: "admin route",
|
||||
notification: acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700001-0",
|
||||
NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
|
||||
Producer: intentstream.ProducerGameLobby,
|
||||
AudienceKind: intentstream.AudienceKindAdminEmail,
|
||||
PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`,
|
||||
IdempotencyKey: "game-456:application-submitted:user-42",
|
||||
RequestFingerprint: "sha256:cafebabe",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
route: acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700001-0",
|
||||
RouteID: "email:email:owner@example.com",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "email:owner@example.com",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "owner@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
wantDeliveryID: "1775121700001-0/email:email:owner@example.com",
|
||||
wantIdempotency: "notification:1775121700001-0/email:email:owner@example.com",
|
||||
wantPayloadJSON: `{"to":["owner@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"lobby.application.submitted","locale":"en","variables":{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"},"attachments":[]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := Encoder{}.Encode(tt.notification, tt.route)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantDeliveryID, command.DeliveryID)
|
||||
require.Equal(t, tt.wantIdempotency, command.IdempotencyKey)
|
||||
require.Equal(t, now, command.RequestedAt)
|
||||
require.JSONEq(t, tt.wantPayloadJSON, command.PayloadJSON)
|
||||
|
||||
values := command.Values()
|
||||
require.Equal(t, tt.wantDeliveryID, values["delivery_id"])
|
||||
require.Equal(t, "notification", values["source"])
|
||||
require.Equal(t, "template", values["payload_mode"])
|
||||
require.Equal(t, tt.wantIdempotency, values["idempotency_key"])
|
||||
require.Equal(t, "1775121700000", values["requested_at_ms"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncoderPropagatesTracingMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
command, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
IdempotencyKey: "game-123:turn-54",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
RequestID: "request-1",
|
||||
TraceID: "trace-1",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "email:user:user-1",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
values := command.Values()
|
||||
require.Equal(t, "request-1", values["request_id"])
|
||||
require.Equal(t, "trace-1", values["trace_id"])
|
||||
}
|
||||
|
||||
func TestEncoderPreservesNormalizedPayloadAsTemplateVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
command, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameFinished,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"final_turn_number":81,"game_id":"game-123","game_name":"Nebula Clash"}`,
|
||||
IdempotencyKey: "game-123:final",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "email:user:user-1",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload struct {
|
||||
Variables map[string]any `json:"variables"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(command.PayloadJSON), &payload))
|
||||
require.Equal(t, map[string]any{
|
||||
"final_turn_number": float64(81),
|
||||
"game_id": "game-123",
|
||||
"game_name": "Nebula Clash",
|
||||
}, payload.Variables)
|
||||
}
|
||||
|
||||
func TestEncoderUsesEmptyAncillaryEnvelopeFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
command, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeLobbyInviteExpired,
|
||||
Producer: intentstream.ProducerGameLobby,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"}`,
|
||||
IdempotencyKey: "game-123:invite-expired",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "email:user:user-1",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.JSONEq(
|
||||
t,
|
||||
`{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"lobby.invite.expired","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"},"attachments":[]}`,
|
||||
command.PayloadJSON,
|
||||
)
|
||||
}
|
||||
|
||||
func TestEncoderRejectsInvalidRouteForMailPublication(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
_, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
IdempotencyKey: "game-123:turn-54",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "push:user:user-1",
|
||||
Channel: intentstream.ChannelPush,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 3,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, `route channel "push" is unsupported`)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package publishpush encodes user-facing notification routes into Gateway
|
||||
// client-event payloads.
|
||||
package publishpush
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
"galaxy/transcoder"
|
||||
)
|
||||
|
||||
// Event stores one Gateway-compatible client event produced from a
|
||||
// user-targeted notification route.
|
||||
type Event struct {
|
||||
// UserID stores the authenticated user fan-out target.
|
||||
UserID string
|
||||
|
||||
// EventType stores the stable client-facing event type.
|
||||
EventType string
|
||||
|
||||
// EventID stores the stable route-level event identifier.
|
||||
EventID string
|
||||
|
||||
// PayloadBytes stores the encoded FlatBuffers payload bytes.
|
||||
PayloadBytes []byte
|
||||
|
||||
// RequestID stores the optional correlation identifier.
|
||||
RequestID string
|
||||
|
||||
// TraceID stores the optional tracing correlation identifier.
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// Encoder maps one supported notification_type to the corresponding checked-in
|
||||
// FlatBuffers payload encoder.
|
||||
type Encoder struct{}
|
||||
|
||||
// Encode converts one accepted notification record plus its push route into a
|
||||
// Gateway-compatible client event.
|
||||
func (Encoder) Encode(notification acceptintent.NotificationRecord, route acceptintent.NotificationRoute) (Event, error) {
|
||||
if err := notification.Validate(); err != nil {
|
||||
return Event{}, fmt.Errorf("encode push event: %w", err)
|
||||
}
|
||||
if err := route.Validate(); err != nil {
|
||||
return Event{}, fmt.Errorf("encode push event: %w", err)
|
||||
}
|
||||
if route.Channel != intentstream.ChannelPush {
|
||||
return Event{}, fmt.Errorf("encode push event: route channel %q is unsupported", route.Channel)
|
||||
}
|
||||
|
||||
userID, err := userIDFromRecipientRef(route.RecipientRef)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("encode push event: %w", err)
|
||||
}
|
||||
|
||||
payloadBytes, err := encodePayload(notification.NotificationType, notification.PayloadJSON)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("encode push event: %w", err)
|
||||
}
|
||||
|
||||
return Event{
|
||||
UserID: userID,
|
||||
EventType: string(notification.NotificationType),
|
||||
EventID: notification.NotificationID + "/" + route.RouteID,
|
||||
PayloadBytes: payloadBytes,
|
||||
RequestID: notification.RequestID,
|
||||
TraceID: notification.TraceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodePayload(notificationType intentstream.NotificationType, payloadJSON string) ([]byte, error) {
|
||||
switch notificationType {
|
||||
case intentstream.NotificationTypeGameTurnReady:
|
||||
var payload struct {
|
||||
GameID string `json:"game_id"`
|
||||
TurnNumber int64 `json:"turn_number"`
|
||||
}
|
||||
if err := decodePayload(payloadJSON, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.GameID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: game_id is empty")
|
||||
}
|
||||
if payload.TurnNumber < 1 {
|
||||
return nil, errors.New("payload_encoding_failed: turn_number must be at least 1")
|
||||
}
|
||||
return wrapPayloadEncoding(transcoder.GameTurnReadyEventToPayload(&transcoder.GameTurnReadyEvent{
|
||||
GameID: payload.GameID,
|
||||
TurnNumber: payload.TurnNumber,
|
||||
}))
|
||||
case intentstream.NotificationTypeGameFinished:
|
||||
var payload struct {
|
||||
GameID string `json:"game_id"`
|
||||
FinalTurnNumber int64 `json:"final_turn_number"`
|
||||
}
|
||||
if err := decodePayload(payloadJSON, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.GameID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: game_id is empty")
|
||||
}
|
||||
if payload.FinalTurnNumber < 1 {
|
||||
return nil, errors.New("payload_encoding_failed: final_turn_number must be at least 1")
|
||||
}
|
||||
return wrapPayloadEncoding(transcoder.GameFinishedEventToPayload(&transcoder.GameFinishedEvent{
|
||||
GameID: payload.GameID,
|
||||
FinalTurnNumber: payload.FinalTurnNumber,
|
||||
}))
|
||||
case intentstream.NotificationTypeLobbyApplicationSubmitted:
|
||||
var payload struct {
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
}
|
||||
if err := decodePayload(payloadJSON, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.GameID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: game_id is empty")
|
||||
}
|
||||
if payload.ApplicantUserID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: applicant_user_id is empty")
|
||||
}
|
||||
return wrapPayloadEncoding(transcoder.LobbyApplicationSubmittedEventToPayload(&transcoder.LobbyApplicationSubmittedEvent{
|
||||
GameID: payload.GameID,
|
||||
ApplicantUserID: payload.ApplicantUserID,
|
||||
}))
|
||||
case intentstream.NotificationTypeLobbyMembershipApproved:
|
||||
var payload struct {
|
||||
GameID string `json:"game_id"`
|
||||
}
|
||||
if err := decodePayload(payloadJSON, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.GameID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: game_id is empty")
|
||||
}
|
||||
return wrapPayloadEncoding(transcoder.LobbyMembershipApprovedEventToPayload(&transcoder.LobbyMembershipApprovedEvent{
|
||||
GameID: payload.GameID,
|
||||
}))
|
||||
case intentstream.NotificationTypeLobbyMembershipRejected:
|
||||
var payload struct {
|
||||
GameID string `json:"game_id"`
|
||||
}
|
||||
if err := decodePayload(payloadJSON, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.GameID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: game_id is empty")
|
||||
}
|
||||
return wrapPayloadEncoding(transcoder.LobbyMembershipRejectedEventToPayload(&transcoder.LobbyMembershipRejectedEvent{
|
||||
GameID: payload.GameID,
|
||||
}))
|
||||
case intentstream.NotificationTypeLobbyInviteCreated:
|
||||
var payload struct {
|
||||
GameID string `json:"game_id"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
}
|
||||
if err := decodePayload(payloadJSON, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.GameID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: game_id is empty")
|
||||
}
|
||||
if payload.InviterUserID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: inviter_user_id is empty")
|
||||
}
|
||||
return wrapPayloadEncoding(transcoder.LobbyInviteCreatedEventToPayload(&transcoder.LobbyInviteCreatedEvent{
|
||||
GameID: payload.GameID,
|
||||
InviterUserID: payload.InviterUserID,
|
||||
}))
|
||||
case intentstream.NotificationTypeLobbyInviteRedeemed:
|
||||
var payload struct {
|
||||
GameID string `json:"game_id"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
}
|
||||
if err := decodePayload(payloadJSON, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.GameID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: game_id is empty")
|
||||
}
|
||||
if payload.InviteeUserID == "" {
|
||||
return nil, errors.New("payload_encoding_failed: invitee_user_id is empty")
|
||||
}
|
||||
return wrapPayloadEncoding(transcoder.LobbyInviteRedeemedEventToPayload(&transcoder.LobbyInviteRedeemedEvent{
|
||||
GameID: payload.GameID,
|
||||
InviteeUserID: payload.InviteeUserID,
|
||||
}))
|
||||
default:
|
||||
return nil, fmt.Errorf("payload_encoding_failed: notification type %q does not support push", notificationType)
|
||||
}
|
||||
}
|
||||
|
||||
func decodePayload(payloadJSON string, target any) error {
|
||||
if err := json.Unmarshal([]byte(payloadJSON), target); err != nil {
|
||||
return fmt.Errorf("payload_encoding_failed: decode payload_json: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapPayloadEncoding(payload []byte, err error) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payload_encoding_failed: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func userIDFromRecipientRef(recipientRef string) (string, error) {
|
||||
userID, ok := strings.CutPrefix(recipientRef, "user:")
|
||||
if !ok || userID == "" {
|
||||
return "", fmt.Errorf("recipient_ref %q is not user-targeted", recipientRef)
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package publishpush
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
"galaxy/transcoder"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncoderEncodesSupportedPushNotificationTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
tests := []struct {
|
||||
name string
|
||||
notificationType intentstream.NotificationType
|
||||
payloadJSON string
|
||||
assertPayload func(*testing.T, []byte)
|
||||
}{
|
||||
{
|
||||
name: "game turn ready",
|
||||
notificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","turn_number":54}`,
|
||||
assertPayload: func(t *testing.T, payload []byte) {
|
||||
t.Helper()
|
||||
event, err := transcoder.PayloadToGameTurnReadyEvent(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "game-1", event.GameID)
|
||||
require.Equal(t, int64(54), event.TurnNumber)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "game finished",
|
||||
notificationType: intentstream.NotificationTypeGameFinished,
|
||||
payloadJSON: `{"final_turn_number":81,"game_id":"game-2","game_name":"Nova"}`,
|
||||
assertPayload: func(t *testing.T, payload []byte) {
|
||||
t.Helper()
|
||||
event, err := transcoder.PayloadToGameFinishedEvent(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "game-2", event.GameID)
|
||||
require.Equal(t, int64(81), event.FinalTurnNumber)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lobby application submitted",
|
||||
notificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
|
||||
payloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-2","game_id":"game-3","game_name":"Orion Front"}`,
|
||||
assertPayload: func(t *testing.T, payload []byte) {
|
||||
t.Helper()
|
||||
event, err := transcoder.PayloadToLobbyApplicationSubmittedEvent(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "game-3", event.GameID)
|
||||
require.Equal(t, "user-2", event.ApplicantUserID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lobby membership approved",
|
||||
notificationType: intentstream.NotificationTypeLobbyMembershipApproved,
|
||||
payloadJSON: `{"game_id":"game-4","game_name":"Ares"}`,
|
||||
assertPayload: func(t *testing.T, payload []byte) {
|
||||
t.Helper()
|
||||
event, err := transcoder.PayloadToLobbyMembershipApprovedEvent(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "game-4", event.GameID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lobby membership rejected",
|
||||
notificationType: intentstream.NotificationTypeLobbyMembershipRejected,
|
||||
payloadJSON: `{"game_id":"game-5","game_name":"Atlas"}`,
|
||||
assertPayload: func(t *testing.T, payload []byte) {
|
||||
t.Helper()
|
||||
event, err := transcoder.PayloadToLobbyMembershipRejectedEvent(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "game-5", event.GameID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lobby invite created",
|
||||
notificationType: intentstream.NotificationTypeLobbyInviteCreated,
|
||||
payloadJSON: `{"game_id":"game-6","game_name":"Vega","inviter_name":"Nova Pilot","inviter_user_id":"user-9"}`,
|
||||
assertPayload: func(t *testing.T, payload []byte) {
|
||||
t.Helper()
|
||||
event, err := transcoder.PayloadToLobbyInviteCreatedEvent(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "game-6", event.GameID)
|
||||
require.Equal(t, "user-9", event.InviterUserID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lobby invite redeemed",
|
||||
notificationType: intentstream.NotificationTypeLobbyInviteRedeemed,
|
||||
payloadJSON: `{"game_id":"game-7","game_name":"Lyra","invitee_name":"Skipper","invitee_user_id":"user-10"}`,
|
||||
assertPayload: func(t *testing.T, payload []byte) {
|
||||
t.Helper()
|
||||
event, err := transcoder.PayloadToLobbyInviteRedeemedEvent(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "game-7", event.GameID)
|
||||
require.Equal(t, "user-10", event.InviteeUserID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: tt.notificationType,
|
||||
Producer: tt.notificationType.ExpectedProducer(),
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: tt.payloadJSON,
|
||||
IdempotencyKey: "idem-1",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
RequestID: "request-1",
|
||||
TraceID: "trace-1",
|
||||
OccurredAt: now,
|
||||
AcceptedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "push:user:user-1",
|
||||
Channel: intentstream.ChannelPush,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 3,
|
||||
NextAttemptAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-1", event.UserID)
|
||||
require.Equal(t, string(tt.notificationType), event.EventType)
|
||||
require.Equal(t, "1775121700000-0/push:user:user-1", event.EventID)
|
||||
require.Equal(t, "request-1", event.RequestID)
|
||||
require.Equal(t, "trace-1", event.TraceID)
|
||||
require.NotEmpty(t, event.PayloadBytes)
|
||||
tt.assertPayload(t, event.PayloadBytes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncoderRejectsInvalidStoredPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
_, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"","game_name":"Nebula Clash","turn_number":0}`,
|
||||
IdempotencyKey: "idem-1",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
OccurredAt: now,
|
||||
AcceptedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "push:user:user-1",
|
||||
Channel: intentstream.ChannelPush,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 3,
|
||||
NextAttemptAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "payload_encoding_failed")
|
||||
}
|
||||
Reference in New Issue
Block a user