package redisstate import ( "bytes" "encoding/json" "fmt" "io" "time" "galaxy/notification/internal/api/intentstream" "galaxy/notification/internal/service/acceptintent" "galaxy/notification/internal/service/malformedintent" ) // StreamOffset stores the persisted progress of the plain-XREAD intent // consumer. type StreamOffset struct { // Stream stores the Redis Stream name. Stream string // LastProcessedEntryID stores the last durably processed Redis Stream entry // identifier. LastProcessedEntryID string // UpdatedAt stores when the offset record was last updated. UpdatedAt time.Time } // DeadLetterEntry stores one terminal route-publication failure recorded for // later operator inspection. type DeadLetterEntry struct { // NotificationID stores the owning notification identifier. NotificationID string // RouteID stores the exhausted route identifier. RouteID string // Channel stores the failed route channel. Channel intentstream.Channel // RecipientRef stores the stable failed recipient slot identifier. RecipientRef string // FinalAttemptCount stores how many publication attempts were consumed. FinalAttemptCount int // MaxAttempts stores the configured retry budget for Channel. MaxAttempts int // FailureClassification stores the stable classified failure reason. FailureClassification string // FailureMessage stores the last failure detail. FailureMessage string // CreatedAt stores when the route moved to dead_letter. CreatedAt time.Time // RecoveryHint stores the optional operator-facing recovery hint. RecoveryHint string } type notificationRecordJSON struct { NotificationID string `json:"notification_id"` NotificationType intentstream.NotificationType `json:"notification_type"` Producer intentstream.Producer `json:"producer"` AudienceKind intentstream.AudienceKind `json:"audience_kind"` RecipientUserIDs []string `json:"recipient_user_ids,omitempty"` PayloadJSON string `json:"payload_json"` IdempotencyKey string `json:"idempotency_key"` RequestFingerprint string `json:"request_fingerprint"` RequestID string `json:"request_id,omitempty"` TraceID string `json:"trace_id,omitempty"` OccurredAtMS int64 `json:"occurred_at_ms"` AcceptedAtMS int64 `json:"accepted_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type notificationRouteJSON struct { NotificationID string `json:"notification_id"` RouteID string `json:"route_id"` Channel intentstream.Channel `json:"channel"` RecipientRef string `json:"recipient_ref"` Status acceptintent.RouteStatus `json:"status"` AttemptCount int `json:"attempt_count"` MaxAttempts int `json:"max_attempts"` NextAttemptAtMS *int64 `json:"next_attempt_at_ms,omitempty"` ResolvedEmail string `json:"resolved_email,omitempty"` ResolvedLocale string `json:"resolved_locale,omitempty"` LastErrorClassification string `json:"last_error_classification,omitempty"` LastErrorMessage string `json:"last_error_message,omitempty"` LastErrorAtMS *int64 `json:"last_error_at_ms,omitempty"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` PublishedAtMS *int64 `json:"published_at_ms,omitempty"` DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"` SkippedAtMS *int64 `json:"skipped_at_ms,omitempty"` } type idempotencyRecordJSON struct { Producer intentstream.Producer `json:"producer"` IdempotencyKey string `json:"idempotency_key"` NotificationID string `json:"notification_id"` RequestFingerprint string `json:"request_fingerprint"` CreatedAtMS int64 `json:"created_at_ms"` ExpiresAtMS int64 `json:"expires_at_ms"` } type malformedIntentJSON struct { StreamEntryID string `json:"stream_entry_id"` NotificationType string `json:"notification_type,omitempty"` Producer string `json:"producer,omitempty"` IdempotencyKey string `json:"idempotency_key,omitempty"` FailureCode malformedintent.FailureCode `json:"failure_code"` FailureMessage string `json:"failure_message"` RawFields map[string]any `json:"raw_fields_json"` RecordedAtMS int64 `json:"recorded_at_ms"` } type streamOffsetJSON struct { Stream string `json:"stream"` LastProcessedEntryID string `json:"last_processed_entry_id"` UpdatedAtMS int64 `json:"updated_at_ms"` } type deadLetterEntryJSON struct { NotificationID string `json:"notification_id"` RouteID string `json:"route_id"` Channel intentstream.Channel `json:"channel"` RecipientRef string `json:"recipient_ref"` FinalAttemptCount int `json:"final_attempt_count"` MaxAttempts int `json:"max_attempts"` FailureClassification string `json:"failure_classification"` FailureMessage string `json:"failure_message"` CreatedAtMS int64 `json:"created_at_ms"` RecoveryHint string `json:"recovery_hint,omitempty"` } // MarshalNotification marshals one notification record into the strict JSON // representation owned by Notification Service. func MarshalNotification(record acceptintent.NotificationRecord) ([]byte, error) { if err := record.Validate(); err != nil { return nil, fmt.Errorf("marshal notification record: %w", err) } return marshalStrictJSON(notificationRecordJSON{ NotificationID: record.NotificationID, NotificationType: record.NotificationType, Producer: record.Producer, AudienceKind: record.AudienceKind, RecipientUserIDs: append([]string(nil), record.RecipientUserIDs...), PayloadJSON: record.PayloadJSON, IdempotencyKey: record.IdempotencyKey, RequestFingerprint: record.RequestFingerprint, RequestID: record.RequestID, TraceID: record.TraceID, OccurredAtMS: unixMilli(record.OccurredAt), AcceptedAtMS: unixMilli(record.AcceptedAt), UpdatedAtMS: unixMilli(record.UpdatedAt), }) } // UnmarshalNotification unmarshals one strict JSON notification record. func UnmarshalNotification(payload []byte) (acceptintent.NotificationRecord, error) { var wire notificationRecordJSON if err := unmarshalStrictJSON(payload, &wire); err != nil { return acceptintent.NotificationRecord{}, fmt.Errorf("unmarshal notification record: %w", err) } record := acceptintent.NotificationRecord{ NotificationID: wire.NotificationID, NotificationType: wire.NotificationType, Producer: wire.Producer, AudienceKind: wire.AudienceKind, RecipientUserIDs: append([]string(nil), wire.RecipientUserIDs...), PayloadJSON: wire.PayloadJSON, IdempotencyKey: wire.IdempotencyKey, RequestFingerprint: wire.RequestFingerprint, RequestID: wire.RequestID, TraceID: wire.TraceID, OccurredAt: time.UnixMilli(wire.OccurredAtMS).UTC(), AcceptedAt: time.UnixMilli(wire.AcceptedAtMS).UTC(), UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(), } if err := record.Validate(); err != nil { return acceptintent.NotificationRecord{}, fmt.Errorf("unmarshal notification record: %w", err) } return record, nil } // MarshalRoute marshals one notification route into the strict JSON // representation owned by Notification Service. func MarshalRoute(route acceptintent.NotificationRoute) ([]byte, error) { if err := route.Validate(); err != nil { return nil, fmt.Errorf("marshal notification route: %w", err) } return marshalStrictJSON(notificationRouteJSON{ NotificationID: route.NotificationID, RouteID: route.RouteID, Channel: route.Channel, RecipientRef: route.RecipientRef, Status: route.Status, AttemptCount: route.AttemptCount, MaxAttempts: route.MaxAttempts, NextAttemptAtMS: optionalUnixMilli(route.NextAttemptAt), ResolvedEmail: route.ResolvedEmail, ResolvedLocale: route.ResolvedLocale, LastErrorClassification: route.LastErrorClassification, LastErrorMessage: route.LastErrorMessage, LastErrorAtMS: optionalUnixMilli(route.LastErrorAt), CreatedAtMS: unixMilli(route.CreatedAt), UpdatedAtMS: unixMilli(route.UpdatedAt), PublishedAtMS: optionalUnixMilli(route.PublishedAt), DeadLetteredAtMS: optionalUnixMilli(route.DeadLetteredAt), SkippedAtMS: optionalUnixMilli(route.SkippedAt), }) } // UnmarshalRoute unmarshals one strict JSON notification route. func UnmarshalRoute(payload []byte) (acceptintent.NotificationRoute, error) { var wire notificationRouteJSON if err := unmarshalStrictJSON(payload, &wire); err != nil { return acceptintent.NotificationRoute{}, fmt.Errorf("unmarshal notification route: %w", err) } route := acceptintent.NotificationRoute{ NotificationID: wire.NotificationID, RouteID: wire.RouteID, Channel: wire.Channel, RecipientRef: wire.RecipientRef, Status: wire.Status, AttemptCount: wire.AttemptCount, MaxAttempts: wire.MaxAttempts, ResolvedEmail: wire.ResolvedEmail, ResolvedLocale: wire.ResolvedLocale, LastErrorClassification: wire.LastErrorClassification, LastErrorMessage: wire.LastErrorMessage, CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(), UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(), } if wire.NextAttemptAtMS != nil { route.NextAttemptAt = time.UnixMilli(*wire.NextAttemptAtMS).UTC() } if wire.LastErrorAtMS != nil { route.LastErrorAt = time.UnixMilli(*wire.LastErrorAtMS).UTC() } if wire.PublishedAtMS != nil { route.PublishedAt = time.UnixMilli(*wire.PublishedAtMS).UTC() } if wire.DeadLetteredAtMS != nil { route.DeadLetteredAt = time.UnixMilli(*wire.DeadLetteredAtMS).UTC() } if wire.SkippedAtMS != nil { route.SkippedAt = time.UnixMilli(*wire.SkippedAtMS).UTC() } if err := route.Validate(); err != nil { return acceptintent.NotificationRoute{}, fmt.Errorf("unmarshal notification route: %w", err) } return route, nil } // MarshalIdempotency marshals one idempotency record into the strict JSON // representation owned by Notification Service. func MarshalIdempotency(record acceptintent.IdempotencyRecord) ([]byte, error) { if err := record.Validate(); err != nil { return nil, fmt.Errorf("marshal notification idempotency record: %w", err) } return marshalStrictJSON(idempotencyRecordJSON{ Producer: record.Producer, IdempotencyKey: record.IdempotencyKey, NotificationID: record.NotificationID, RequestFingerprint: record.RequestFingerprint, CreatedAtMS: unixMilli(record.CreatedAt), ExpiresAtMS: unixMilli(record.ExpiresAt), }) } // UnmarshalIdempotency unmarshals one strict JSON idempotency record. func UnmarshalIdempotency(payload []byte) (acceptintent.IdempotencyRecord, error) { var wire idempotencyRecordJSON if err := unmarshalStrictJSON(payload, &wire); err != nil { return acceptintent.IdempotencyRecord{}, fmt.Errorf("unmarshal notification idempotency record: %w", err) } record := acceptintent.IdempotencyRecord{ Producer: wire.Producer, IdempotencyKey: wire.IdempotencyKey, NotificationID: wire.NotificationID, RequestFingerprint: wire.RequestFingerprint, CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(), ExpiresAt: time.UnixMilli(wire.ExpiresAtMS).UTC(), } if err := record.Validate(); err != nil { return acceptintent.IdempotencyRecord{}, fmt.Errorf("unmarshal notification idempotency record: %w", err) } return record, nil } // MarshalDeadLetter marshals one dead-letter entry into the strict JSON // representation owned by Notification Service. func MarshalDeadLetter(entry DeadLetterEntry) ([]byte, error) { if err := entry.Validate(); err != nil { return nil, fmt.Errorf("marshal dead letter entry: %w", err) } return marshalStrictJSON(deadLetterEntryJSON{ NotificationID: entry.NotificationID, RouteID: entry.RouteID, Channel: entry.Channel, RecipientRef: entry.RecipientRef, FinalAttemptCount: entry.FinalAttemptCount, MaxAttempts: entry.MaxAttempts, FailureClassification: entry.FailureClassification, FailureMessage: entry.FailureMessage, CreatedAtMS: unixMilli(entry.CreatedAt), RecoveryHint: entry.RecoveryHint, }) } // UnmarshalDeadLetter unmarshals one strict JSON dead-letter entry. func UnmarshalDeadLetter(payload []byte) (DeadLetterEntry, error) { var wire deadLetterEntryJSON if err := unmarshalStrictJSON(payload, &wire); err != nil { return DeadLetterEntry{}, fmt.Errorf("unmarshal dead letter entry: %w", err) } entry := DeadLetterEntry{ NotificationID: wire.NotificationID, RouteID: wire.RouteID, Channel: wire.Channel, RecipientRef: wire.RecipientRef, FinalAttemptCount: wire.FinalAttemptCount, MaxAttempts: wire.MaxAttempts, FailureClassification: wire.FailureClassification, FailureMessage: wire.FailureMessage, CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(), RecoveryHint: wire.RecoveryHint, } if err := entry.Validate(); err != nil { return DeadLetterEntry{}, fmt.Errorf("unmarshal dead letter entry: %w", err) } return entry, nil } // MarshalMalformedIntent marshals one malformed-intent entry into the strict // JSON representation owned by Notification Service. func MarshalMalformedIntent(entry malformedintent.Entry) ([]byte, error) { if err := entry.Validate(); err != nil { return nil, fmt.Errorf("marshal malformed intent: %w", err) } return marshalStrictJSON(malformedIntentJSON{ StreamEntryID: entry.StreamEntryID, NotificationType: entry.NotificationType, Producer: entry.Producer, IdempotencyKey: entry.IdempotencyKey, FailureCode: entry.FailureCode, FailureMessage: entry.FailureMessage, RawFields: cloneJSONObject(entry.RawFields), RecordedAtMS: unixMilli(entry.RecordedAt), }) } // UnmarshalMalformedIntent unmarshals one strict JSON malformed-intent entry. func UnmarshalMalformedIntent(payload []byte) (malformedintent.Entry, error) { var wire malformedIntentJSON if err := unmarshalStrictJSON(payload, &wire); err != nil { return malformedintent.Entry{}, fmt.Errorf("unmarshal malformed intent: %w", err) } entry := malformedintent.Entry{ StreamEntryID: wire.StreamEntryID, NotificationType: wire.NotificationType, Producer: wire.Producer, IdempotencyKey: wire.IdempotencyKey, FailureCode: wire.FailureCode, FailureMessage: wire.FailureMessage, RawFields: cloneJSONObject(wire.RawFields), RecordedAt: time.UnixMilli(wire.RecordedAtMS).UTC(), } if err := entry.Validate(); err != nil { return malformedintent.Entry{}, fmt.Errorf("unmarshal malformed intent: %w", err) } return entry, nil } // MarshalStreamOffset marshals one stream-offset record into the strict JSON // representation owned by Notification Service. func MarshalStreamOffset(offset StreamOffset) ([]byte, error) { if err := offset.Validate(); err != nil { return nil, fmt.Errorf("marshal stream offset: %w", err) } return marshalStrictJSON(streamOffsetJSON{ Stream: offset.Stream, LastProcessedEntryID: offset.LastProcessedEntryID, UpdatedAtMS: unixMilli(offset.UpdatedAt), }) } // UnmarshalStreamOffset unmarshals one strict JSON stream-offset record. func UnmarshalStreamOffset(payload []byte) (StreamOffset, error) { var wire streamOffsetJSON if err := unmarshalStrictJSON(payload, &wire); err != nil { return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err) } offset := StreamOffset{ Stream: wire.Stream, LastProcessedEntryID: wire.LastProcessedEntryID, UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(), } if err := offset.Validate(); err != nil { return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err) } return offset, nil } // Validate reports whether offset contains a complete persisted consumer // progress record. func (offset StreamOffset) Validate() error { if offset.Stream == "" { return fmt.Errorf("stream offset stream must not be empty") } if offset.LastProcessedEntryID == "" { return fmt.Errorf("stream offset last processed entry id must not be empty") } if offset.UpdatedAt.IsZero() { return fmt.Errorf("stream offset updated at must not be zero") } if !offset.UpdatedAt.Equal(offset.UpdatedAt.UTC()) { return fmt.Errorf("stream offset updated at must be UTC") } if !offset.UpdatedAt.Equal(offset.UpdatedAt.Truncate(time.Millisecond)) { return fmt.Errorf("stream offset updated at must use millisecond precision") } return nil } // Validate reports whether entry contains a complete dead-letter record. func (entry DeadLetterEntry) Validate() error { if entry.NotificationID == "" { return fmt.Errorf("dead letter entry notification id must not be empty") } if entry.RouteID == "" { return fmt.Errorf("dead letter entry route id must not be empty") } if !entry.Channel.IsKnown() { return fmt.Errorf("dead letter entry channel %q is unsupported", entry.Channel) } if entry.RecipientRef == "" { return fmt.Errorf("dead letter entry recipient ref must not be empty") } if entry.FinalAttemptCount <= 0 { return fmt.Errorf("dead letter entry final attempt count must be positive") } if entry.MaxAttempts <= 0 { return fmt.Errorf("dead letter entry max attempts must be positive") } if entry.FailureClassification == "" { return fmt.Errorf("dead letter entry failure classification must not be empty") } if entry.FailureMessage == "" { return fmt.Errorf("dead letter entry failure message must not be empty") } if entry.CreatedAt.IsZero() { return fmt.Errorf("dead letter entry created at must not be zero") } if !entry.CreatedAt.Equal(entry.CreatedAt.UTC()) { return fmt.Errorf("dead letter entry created at must be UTC") } if !entry.CreatedAt.Equal(entry.CreatedAt.Truncate(time.Millisecond)) { return fmt.Errorf("dead letter entry created at must use millisecond precision") } return nil } func marshalStrictJSON(value any) ([]byte, error) { return json.Marshal(value) } func unmarshalStrictJSON(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewBuffer(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return fmt.Errorf("unexpected trailing JSON input") } return err } return nil } func unixMilli(value time.Time) int64 { return value.UTC().UnixMilli() } func optionalUnixMilli(value time.Time) *int64 { if value.IsZero() { return nil } millis := unixMilli(value) return &millis } func cloneJSONObject(value map[string]any) map[string]any { if value == nil { return map[string]any{} } cloned := make(map[string]any, len(value)) for key, raw := range value { cloned[key] = cloneJSONValue(raw) } return cloned } func cloneJSONValue(value any) any { switch typed := value.(type) { case map[string]any: return cloneJSONObject(typed) case []any: cloned := make([]any, len(typed)) for index, item := range typed { cloned[index] = cloneJSONValue(item) } return cloned default: return typed } }