package ports import ( "context" "fmt" "strings" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" ) const ( // ProfileChangedEventType identifies profile-change events in the shared // auxiliary event stream. ProfileChangedEventType = "user.profile.changed" // SettingsChangedEventType identifies settings-change events in the shared // auxiliary event stream. SettingsChangedEventType = "user.settings.changed" // EntitlementChangedEventType identifies entitlement-change events in the // shared auxiliary event stream. EntitlementChangedEventType = "user.entitlement.changed" // SanctionChangedEventType identifies sanction-change events in the shared // auxiliary event stream. SanctionChangedEventType = "user.sanction.changed" // LimitChangedEventType identifies limit-change events in the shared // auxiliary event stream. LimitChangedEventType = "user.limit.changed" ) // ProfileChangedOperation identifies one profile-change event kind. type ProfileChangedOperation string const ( // ProfileChangedOperationInitialized reports the initial account // materialization performed during auth-driven user creation. ProfileChangedOperationInitialized ProfileChangedOperation = "initialized" // ProfileChangedOperationUpdated reports a later self-service profile // update. ProfileChangedOperationUpdated ProfileChangedOperation = "updated" ) // IsKnown reports whether operation belongs to the frozen profile-change // event vocabulary. func (operation ProfileChangedOperation) IsKnown() bool { switch operation { case ProfileChangedOperationInitialized, ProfileChangedOperationUpdated: return true default: return false } } // SettingsChangedOperation identifies one settings-change event kind. type SettingsChangedOperation string const ( // SettingsChangedOperationInitialized reports the initial account settings // materialization performed during auth-driven user creation. SettingsChangedOperationInitialized SettingsChangedOperation = "initialized" // SettingsChangedOperationUpdated reports a later self-service settings // update. SettingsChangedOperationUpdated SettingsChangedOperation = "updated" ) // IsKnown reports whether operation belongs to the frozen settings-change // event vocabulary. func (operation SettingsChangedOperation) IsKnown() bool { switch operation { case SettingsChangedOperationInitialized, SettingsChangedOperationUpdated: return true default: return false } } // EntitlementChangedOperation identifies one entitlement-change event kind. type EntitlementChangedOperation string const ( // EntitlementChangedOperationInitialized reports the initial free snapshot // created for a new user. EntitlementChangedOperationInitialized EntitlementChangedOperation = "initialized" // EntitlementChangedOperationGranted reports an explicit paid grant. EntitlementChangedOperationGranted EntitlementChangedOperation = "granted" // EntitlementChangedOperationExtended reports an explicit paid extension. EntitlementChangedOperationExtended EntitlementChangedOperation = "extended" // EntitlementChangedOperationRevoked reports an explicit paid revoke. EntitlementChangedOperationRevoked EntitlementChangedOperation = "revoked" // EntitlementChangedOperationExpiredRepaired reports lazy repair of a // naturally expired finite paid snapshot. EntitlementChangedOperationExpiredRepaired EntitlementChangedOperation = "expired_repaired" ) // IsKnown reports whether operation belongs to the frozen entitlement-change // event vocabulary. func (operation EntitlementChangedOperation) IsKnown() bool { switch operation { case EntitlementChangedOperationInitialized, EntitlementChangedOperationGranted, EntitlementChangedOperationExtended, EntitlementChangedOperationRevoked, EntitlementChangedOperationExpiredRepaired: return true default: return false } } // SanctionChangedOperation identifies one sanction-change event kind. type SanctionChangedOperation string const ( // SanctionChangedOperationApplied reports a new active sanction. SanctionChangedOperationApplied SanctionChangedOperation = "applied" // SanctionChangedOperationRemoved reports explicit removal of an active // sanction. SanctionChangedOperationRemoved SanctionChangedOperation = "removed" ) // IsKnown reports whether operation belongs to the frozen sanction-change // event vocabulary. func (operation SanctionChangedOperation) IsKnown() bool { switch operation { case SanctionChangedOperationApplied, SanctionChangedOperationRemoved: return true default: return false } } // LimitChangedOperation identifies one limit-change event kind. type LimitChangedOperation string const ( // LimitChangedOperationSet reports a new or replacement active limit. LimitChangedOperationSet LimitChangedOperation = "set" // LimitChangedOperationRemoved reports explicit removal of an active limit. LimitChangedOperationRemoved LimitChangedOperation = "removed" ) // IsKnown reports whether operation belongs to the frozen limit-change event // vocabulary. func (operation LimitChangedOperation) IsKnown() bool { switch operation { case LimitChangedOperationSet, LimitChangedOperationRemoved: return true default: return false } } // ProfileChangedEvent stores one post-commit auxiliary profile-change event. type ProfileChangedEvent struct { // UserID identifies the changed user. UserID common.UserID // OccurredAt stores the mutation timestamp emitted into the shared event // envelope. OccurredAt time.Time // Source stores the machine-readable mutation source. Source common.Source // TraceID stores the optional OpenTelemetry trace identifier propagated // from the current request context. TraceID string // Operation stores the profile-change event kind. Operation ProfileChangedOperation // UserName stores the immutable handle associated with the account at the // moment the event is published. UserName common.UserName // DisplayName stores the latest display name after the commit. An empty // value is valid and means no display name is set. DisplayName common.DisplayName } // Validate reports whether event is structurally complete. func (event ProfileChangedEvent) Validate() error { if err := validateEventEnvelope("profile changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil { return err } if !event.Operation.IsKnown() { return fmt.Errorf("profile changed event operation %q is unsupported", event.Operation) } if err := event.UserName.Validate(); err != nil { return fmt.Errorf("profile changed event user name: %w", err) } if err := event.DisplayName.Validate(); err != nil { return fmt.Errorf("profile changed event display name: %w", err) } return nil } // SettingsChangedEvent stores one post-commit auxiliary settings-change event. type SettingsChangedEvent struct { // UserID identifies the changed user. UserID common.UserID // OccurredAt stores the mutation timestamp emitted into the shared event // envelope. OccurredAt time.Time // Source stores the machine-readable mutation source. Source common.Source // TraceID stores the optional OpenTelemetry trace identifier propagated // from the current request context. TraceID string // Operation stores the settings-change event kind. Operation SettingsChangedOperation // PreferredLanguage stores the latest preferred language after the commit. PreferredLanguage common.LanguageTag // TimeZone stores the latest time-zone name after the commit. TimeZone common.TimeZoneName } // Validate reports whether event is structurally complete. func (event SettingsChangedEvent) Validate() error { if err := validateEventEnvelope("settings changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil { return err } if !event.Operation.IsKnown() { return fmt.Errorf("settings changed event operation %q is unsupported", event.Operation) } if err := event.PreferredLanguage.Validate(); err != nil { return fmt.Errorf("settings changed event preferred language: %w", err) } if err := event.TimeZone.Validate(); err != nil { return fmt.Errorf("settings changed event time zone: %w", err) } return nil } // EntitlementChangedEvent stores one post-commit auxiliary entitlement-change // event. type EntitlementChangedEvent struct { // UserID identifies the changed user. UserID common.UserID // OccurredAt stores the mutation timestamp emitted into the shared event // envelope. OccurredAt time.Time // Source stores the machine-readable mutation source. Source common.Source // TraceID stores the optional OpenTelemetry trace identifier propagated // from the current request context. TraceID string // Operation stores the entitlement-change event kind. Operation EntitlementChangedOperation // PlanCode stores the effective plan after the commit. PlanCode entitlement.PlanCode // IsPaid stores the effective paid/free flag after the commit. IsPaid bool // StartsAt stores when the effective entitlement state started. StartsAt time.Time // EndsAt stores the optional finite paid expiry. EndsAt *time.Time // ReasonCode stores the mutation reason. ReasonCode common.ReasonCode // Actor stores the audit actor metadata attached to the mutation. Actor common.ActorRef // UpdatedAt stores when the current entitlement snapshot was recomputed. UpdatedAt time.Time } // Validate reports whether event is structurally complete. func (event EntitlementChangedEvent) Validate() error { if err := validateEventEnvelope("entitlement changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil { return err } if !event.Operation.IsKnown() { return fmt.Errorf("entitlement changed event operation %q is unsupported", event.Operation) } if !event.PlanCode.IsKnown() { return fmt.Errorf("entitlement changed event plan code %q is unsupported", event.PlanCode) } if event.IsPaid != event.PlanCode.IsPaid() { return fmt.Errorf("entitlement changed event paid flag must match plan code %q", event.PlanCode) } if err := common.ValidateTimestamp("entitlement changed event starts at", event.StartsAt); err != nil { return err } if event.PlanCode.HasFiniteExpiry() { if event.EndsAt == nil { return fmt.Errorf("entitlement changed event ends at must be present for plan code %q", event.PlanCode) } if !event.EndsAt.After(event.StartsAt) { return common.ErrInvertedTimeRange } } else if event.EndsAt != nil { return fmt.Errorf("entitlement changed event ends at must be empty for plan code %q", event.PlanCode) } if err := event.ReasonCode.Validate(); err != nil { return fmt.Errorf("entitlement changed event reason code: %w", err) } if err := event.Actor.Validate(); err != nil { return fmt.Errorf("entitlement changed event actor: %w", err) } if err := common.ValidateTimestamp("entitlement changed event updated at", event.UpdatedAt); err != nil { return err } return nil } // SanctionChangedEvent stores one post-commit auxiliary sanction-change event. type SanctionChangedEvent struct { // UserID identifies the changed user. UserID common.UserID // OccurredAt stores the mutation timestamp emitted into the shared event // envelope. OccurredAt time.Time // Source stores the machine-readable mutation source. Source common.Source // TraceID stores the optional OpenTelemetry trace identifier propagated // from the current request context. TraceID string // Operation stores the sanction-change event kind. Operation SanctionChangedOperation // SanctionCode stores the affected sanction code. SanctionCode policy.SanctionCode // Scope stores the machine-readable sanction scope. Scope common.Scope // ReasonCode stores the mutation reason. ReasonCode common.ReasonCode // Actor stores the audit actor metadata attached to the mutation. Actor common.ActorRef // AppliedAt stores when the sanction became effective. AppliedAt time.Time // ExpiresAt stores the optional planned sanction expiry. ExpiresAt *time.Time // RemovedAt stores the optional sanction removal timestamp. RemovedAt *time.Time } // Validate reports whether event is structurally complete. func (event SanctionChangedEvent) Validate() error { if err := validateEventEnvelope("sanction changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil { return err } if !event.Operation.IsKnown() { return fmt.Errorf("sanction changed event operation %q is unsupported", event.Operation) } if !event.SanctionCode.IsKnown() { return fmt.Errorf("sanction changed event sanction code %q is unsupported", event.SanctionCode) } if err := event.Scope.Validate(); err != nil { return fmt.Errorf("sanction changed event scope: %w", err) } if err := event.ReasonCode.Validate(); err != nil { return fmt.Errorf("sanction changed event reason code: %w", err) } if err := event.Actor.Validate(); err != nil { return fmt.Errorf("sanction changed event actor: %w", err) } if err := common.ValidateTimestamp("sanction changed event applied at", event.AppliedAt); err != nil { return err } if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) { return common.ErrInvertedTimeRange } if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) { return fmt.Errorf("sanction changed event removed at must not be before applied at") } return nil } // LimitChangedEvent stores one post-commit auxiliary limit-change event. type LimitChangedEvent struct { // UserID identifies the changed user. UserID common.UserID // OccurredAt stores the mutation timestamp emitted into the shared event // envelope. OccurredAt time.Time // Source stores the machine-readable mutation source. Source common.Source // TraceID stores the optional OpenTelemetry trace identifier propagated // from the current request context. TraceID string // Operation stores the limit-change event kind. Operation LimitChangedOperation // LimitCode stores the affected limit code. LimitCode policy.LimitCode // Value stores the active limit value when the operation is `set`. Value *int // ReasonCode stores the mutation reason. ReasonCode common.ReasonCode // Actor stores the audit actor metadata attached to the mutation. Actor common.ActorRef // AppliedAt stores when the limit became effective. AppliedAt time.Time // ExpiresAt stores the optional planned limit expiry. ExpiresAt *time.Time // RemovedAt stores the optional explicit limit removal timestamp. RemovedAt *time.Time } // Validate reports whether event is structurally complete. func (event LimitChangedEvent) Validate() error { if err := validateEventEnvelope("limit changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil { return err } if !event.Operation.IsKnown() { return fmt.Errorf("limit changed event operation %q is unsupported", event.Operation) } if !event.LimitCode.IsSupported() { return fmt.Errorf("limit changed event limit code %q is unsupported", event.LimitCode) } switch event.Operation { case LimitChangedOperationSet: if event.Value == nil { return fmt.Errorf("limit changed event value must be present for operation %q", event.Operation) } if *event.Value < 0 { return fmt.Errorf("limit changed event value must not be negative") } case LimitChangedOperationRemoved: if event.Value != nil && *event.Value < 0 { return fmt.Errorf("limit changed event value must not be negative") } } if err := event.ReasonCode.Validate(); err != nil { return fmt.Errorf("limit changed event reason code: %w", err) } if err := event.Actor.Validate(); err != nil { return fmt.Errorf("limit changed event actor: %w", err) } if err := common.ValidateTimestamp("limit changed event applied at", event.AppliedAt); err != nil { return err } if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) { return common.ErrInvertedTimeRange } if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) { return fmt.Errorf("limit changed event removed at must not be before applied at") } return nil } // ProfileChangedPublisher publishes auxiliary profile-change notifications. type ProfileChangedPublisher interface { // PublishProfileChanged propagates one committed profile-change event. PublishProfileChanged(ctx context.Context, event ProfileChangedEvent) error } // SettingsChangedPublisher publishes auxiliary settings-change notifications. type SettingsChangedPublisher interface { // PublishSettingsChanged propagates one committed settings-change event. PublishSettingsChanged(ctx context.Context, event SettingsChangedEvent) error } // EntitlementChangedPublisher publishes auxiliary entitlement-change // notifications. type EntitlementChangedPublisher interface { // PublishEntitlementChanged propagates one committed entitlement-change // event. PublishEntitlementChanged(ctx context.Context, event EntitlementChangedEvent) error } // SanctionChangedPublisher publishes auxiliary sanction-change notifications. type SanctionChangedPublisher interface { // PublishSanctionChanged propagates one committed sanction-change event. PublishSanctionChanged(ctx context.Context, event SanctionChangedEvent) error } // LimitChangedPublisher publishes auxiliary limit-change notifications. type LimitChangedPublisher interface { // PublishLimitChanged propagates one committed limit-change event. PublishLimitChanged(ctx context.Context, event LimitChangedEvent) error } func validateEventEnvelope(name string, userID common.UserID, occurredAt time.Time, source common.Source, traceID string) error { if err := userID.Validate(); err != nil { return fmt.Errorf("%s user id: %w", name, err) } if err := common.ValidateTimestamp(name+" occurred at", occurredAt); err != nil { return err } if err := source.Validate(); err != nil { return fmt.Errorf("%s source: %w", name, err) } if traceID != "" { if strings.TrimSpace(traceID) != traceID { return fmt.Errorf("%s trace id must not contain surrounding whitespace", name) } } return nil }