feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
@@ -0,0 +1,537 @@
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
// RaceName stores the latest exact race name after the commit.
RaceName common.RaceName
}
// 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.RaceName.Validate(); err != nil {
return fmt.Errorf("profile changed event race 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
}