package ports import ( "context" "fmt" "strings" "time" ) // UserLifecycleEventType identifies one supported user-lifecycle event // kind propagated from User Service to Game Lobby through the // `user:lifecycle_events` Redis Stream. type UserLifecycleEventType string const ( // UserLifecycleEventTypePermanentBlocked identifies the post-commit // event emitted when `SanctionCodePermanentBlock` becomes active on // an account. UserLifecycleEventTypePermanentBlocked UserLifecycleEventType = "user.lifecycle.permanent_blocked" // UserLifecycleEventTypeDeleted identifies the post-commit event // emitted when `DeleteUser` soft-deletes an account. UserLifecycleEventTypeDeleted UserLifecycleEventType = "user.lifecycle.deleted" ) // String returns the wire value for eventType. func (eventType UserLifecycleEventType) String() string { return string(eventType) } // IsKnown reports whether eventType belongs to the frozen vocabulary. func (eventType UserLifecycleEventType) IsKnown() bool { switch eventType { case UserLifecycleEventTypePermanentBlocked, UserLifecycleEventTypeDeleted: return true default: return false } } // UserLifecycleEvent stores the decoded shape of one entry from the // `user:lifecycle_events` Redis Stream. type UserLifecycleEvent struct { // EntryID stores the Redis Streams entry id (`-` form). The // consumer uses it as part of notification idempotency keys so a // retried cascade publishes deterministically the same intent. EntryID string // EventType stores the frozen lifecycle event discriminator. EventType UserLifecycleEventType // UserID identifies the regular user whose lifecycle state changed. UserID string // OccurredAt stores the committed mutation timestamp emitted by User // Service. OccurredAt time.Time // Source stores the machine-readable mutation source. always // emits `admin_internal_api`. Source string // ActorType stores the audit actor type (e.g. `admin_user`, // `system`). ActorType string // ActorID stores the optional audit actor identifier. It is empty // when the upstream event carries no actor id. ActorID string // ReasonCode stores the committed `reason_code` emitted by User // Service. ReasonCode string // TraceID stores the optional OpenTelemetry trace identifier // propagated from the upstream request context. TraceID string } // Validate reports whether event satisfies the structural invariants // required for cascade processing. func (event UserLifecycleEvent) Validate() error { if strings.TrimSpace(event.EntryID) == "" { return fmt.Errorf("user lifecycle event entry id must not be empty") } if !event.EventType.IsKnown() { return fmt.Errorf("user lifecycle event type %q is unsupported", event.EventType) } if strings.TrimSpace(event.UserID) == "" { return fmt.Errorf("user lifecycle event user id must not be empty") } if event.OccurredAt.IsZero() { return fmt.Errorf("user lifecycle event occurred at must not be zero") } return nil } // UserLifecycleHandler processes one decoded lifecycle event. Returning // nil advances the stream offset; returning a non-nil error holds the // offset on the current entry so the consumer retries on the next loop // iteration. type UserLifecycleHandler func(ctx context.Context, event UserLifecycleEvent) error // UserLifecycleConsumer drives the read loop over the // `user:lifecycle_events` Redis Stream. The Redis adapter satisfies the // interface in production; in-process stubs satisfy it in tests so the // cascade worker can be exercised without spinning up Redis. type UserLifecycleConsumer interface { // OnEvent installs handler as the sole dispatcher for decoded events. // A second call replaces the previous handler. OnEvent(handler UserLifecycleHandler) // Run drives the consumer loop until ctx is cancelled. The // implementation is expected to be re-entrant only within a single // goroutine. Run(ctx context.Context) error // Shutdown releases consumer-owned resources after Run has returned. // The consumer must support being closed without an active Run loop. Shutdown(ctx context.Context) error }