// Package selfservice implements the authenticated self-service account read // and mutation use cases owned by User Service. package selfservice import ( "context" "errors" "fmt" "log/slog" "strings" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "galaxy/user/internal/service/accountview" "galaxy/user/internal/service/shared" "galaxy/user/internal/telemetry" ) const gatewaySelfServiceSource = common.Source("gateway_self_service") // ActorRefView stores transport-ready audit actor metadata. type ActorRefView = accountview.ActorRefView // EntitlementSnapshotView stores the transport-ready current entitlement // snapshot of one account. type EntitlementSnapshotView = accountview.EntitlementSnapshotView // ActiveSanctionView stores one transport-ready active sanction. type ActiveSanctionView = accountview.ActiveSanctionView // ActiveLimitView stores one transport-ready active user-specific limit. type ActiveLimitView = accountview.ActiveLimitView // AccountView stores the transport-ready authenticated self-service account // aggregate. type AccountView = accountview.AccountView // GetMyAccountInput stores one authenticated account-read request. type GetMyAccountInput struct { // UserID stores the authenticated regular-user identifier. UserID string } // GetMyAccountResult stores one authenticated account-read result. type GetMyAccountResult struct { // Account stores the read-optimized current account aggregate. Account AccountView `json:"account"` } // UpdateMyProfileInput stores one self-service profile mutation request. type UpdateMyProfileInput struct { // UserID stores the authenticated regular-user identifier. UserID string // RaceName stores the requested exact replacement race name. RaceName string } // UpdateMyProfileResult stores one self-service profile mutation result. type UpdateMyProfileResult struct { // Account stores the refreshed account aggregate after the mutation. Account AccountView `json:"account"` } // UpdateMySettingsInput stores one self-service settings mutation request. type UpdateMySettingsInput struct { // UserID stores the authenticated regular-user identifier. UserID string // PreferredLanguage stores the requested BCP 47 preferred language. PreferredLanguage string // TimeZone stores the requested IANA time-zone name. TimeZone string } // UpdateMySettingsResult stores one self-service settings mutation result. type UpdateMySettingsResult struct { // Account stores the refreshed account aggregate after the mutation. Account AccountView `json:"account"` } type entitlementReader interface { GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) } // AccountGetter executes the `GetMyAccount` use case. type AccountGetter struct { loader *accountview.Loader } // NewAccountGetter constructs one authenticated account-read use case. func NewAccountGetter( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, ) (*AccountGetter, error) { loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock) if err != nil { return nil, fmt.Errorf("selfservice account getter: %w", err) } return &AccountGetter{loader: loader}, nil } // Execute reads the current self-service account aggregate of input.UserID. func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInput) (GetMyAccountResult, error) { if ctx == nil { return GetMyAccountResult{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { return GetMyAccountResult{}, err } state, err := service.loader.Load(ctx, userID) if err != nil { return GetMyAccountResult{}, err } return GetMyAccountResult{Account: state.View()}, nil } // ProfileUpdater executes the `UpdateMyProfile` use case. type ProfileUpdater struct { accounts ports.UserAccountStore loader *accountview.Loader policy ports.RaceNamePolicy clock ports.Clock logger *slog.Logger telemetry *telemetry.Runtime profilePublisher ports.ProfileChangedPublisher } // NewProfileUpdater constructs one self-service profile-mutation use case. func NewProfileUpdater( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, policy ports.RaceNamePolicy, ) (*ProfileUpdater, error) { return NewProfileUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, policy, nil, nil, nil) } // NewProfileUpdaterWithObservability constructs one self-service // profile-mutation use case with optional observability hooks. func NewProfileUpdaterWithObservability( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, policy ports.RaceNamePolicy, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, profilePublisher ports.ProfileChangedPublisher, ) (*ProfileUpdater, error) { loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock) if err != nil { return nil, fmt.Errorf("selfservice profile updater: %w", err) } if policy == nil { return nil, fmt.Errorf("selfservice profile updater: race-name policy must not be nil") } return &ProfileUpdater{ accounts: accounts, loader: loader, policy: policy, clock: clock, logger: logger, telemetry: telemetryRuntime, profilePublisher: profilePublisher, }, nil } // Execute updates the current self-service profile fields of input.UserID. func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfileInput) (result UpdateMyProfileResult, err error) { outcome := "failed" userIDString := "" defer func() { shared.LogServiceOutcome(service.logger, ctx, "profile update completed", err, "use_case", "update_my_profile", "outcome", outcome, "user_id", userIDString, "source", gatewaySelfServiceSource.String(), ) }() if ctx == nil { return UpdateMyProfileResult{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { return UpdateMyProfileResult{}, err } userIDString = userID.String() raceName, err := parseRaceName(input.RaceName) if err != nil { return UpdateMyProfileResult{}, err } state, err := service.loader.Load(ctx, userID) if err != nil { return UpdateMyProfileResult{}, err } if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) { return UpdateMyProfileResult{}, shared.Conflict() } if state.AccountRecord.RaceName == raceName { outcome = "noop" return UpdateMyProfileResult{Account: state.View()}, nil } now := service.clock.Now().UTC() currentCanonicalKey, err := service.policy.CanonicalKey(state.AccountRecord.RaceName) if err != nil { return UpdateMyProfileResult{}, shared.ServiceUnavailable(fmt.Errorf("canonicalize current race name: %w", err)) } reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now) if err != nil { return UpdateMyProfileResult{}, shared.ServiceUnavailable(err) } if err := service.accounts.RenameRaceName(ctx, ports.RenameRaceNameInput{ UserID: userID, CurrentCanonicalKey: currentCanonicalKey, NewRaceName: raceName, NewReservation: reservation, UpdatedAt: now, }); err != nil { if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil { service.telemetry.RecordRaceNameReservationConflict(ctx, "update_my_profile") } switch { case errors.Is(err, ports.ErrNotFound): return UpdateMyProfileResult{}, shared.SubjectNotFound() case errors.Is(err, ports.ErrConflict): return UpdateMyProfileResult{}, shared.Conflict() default: return UpdateMyProfileResult{}, shared.ServiceUnavailable(err) } } updatedState, err := service.loader.Load(ctx, userID) if err != nil { return UpdateMyProfileResult{}, err } outcome = "updated" result = UpdateMyProfileResult{Account: updatedState.View()} service.publishProfileChanged(ctx, updatedState.AccountRecord) return result, nil } // SettingsUpdater executes the `UpdateMySettings` use case. type SettingsUpdater struct { accounts ports.UserAccountStore loader *accountview.Loader clock ports.Clock logger *slog.Logger telemetry *telemetry.Runtime settingsPublisher ports.SettingsChangedPublisher } // NewSettingsUpdater constructs one self-service settings-mutation use case. func NewSettingsUpdater( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, ) (*SettingsUpdater, error) { return NewSettingsUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, nil, nil, nil) } // NewSettingsUpdaterWithObservability constructs one self-service // settings-mutation use case with optional observability hooks. func NewSettingsUpdaterWithObservability( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, settingsPublisher ports.SettingsChangedPublisher, ) (*SettingsUpdater, error) { loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock) if err != nil { return nil, fmt.Errorf("selfservice settings updater: %w", err) } return &SettingsUpdater{ accounts: accounts, loader: loader, clock: clock, logger: logger, telemetry: telemetryRuntime, settingsPublisher: settingsPublisher, }, nil } // Execute updates the current self-service settings fields of input.UserID. func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySettingsInput) (result UpdateMySettingsResult, err error) { outcome := "failed" userIDString := "" defer func() { shared.LogServiceOutcome(service.logger, ctx, "settings update completed", err, "use_case", "update_my_settings", "outcome", outcome, "user_id", userIDString, "source", gatewaySelfServiceSource.String(), ) }() if ctx == nil { return UpdateMySettingsResult{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { return UpdateMySettingsResult{}, err } userIDString = userID.String() preferredLanguage, err := parsePreferredLanguage(input.PreferredLanguage) if err != nil { return UpdateMySettingsResult{}, err } timeZone, err := parseTimeZoneName(input.TimeZone) if err != nil { return UpdateMySettingsResult{}, err } state, err := service.loader.Load(ctx, userID) if err != nil { return UpdateMySettingsResult{}, err } if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) { return UpdateMySettingsResult{}, shared.Conflict() } if state.AccountRecord.PreferredLanguage == preferredLanguage && state.AccountRecord.TimeZone == timeZone { outcome = "noop" return UpdateMySettingsResult{Account: state.View()}, nil } record := state.AccountRecord record.PreferredLanguage = preferredLanguage record.TimeZone = timeZone record.UpdatedAt = service.clock.Now().UTC() if err := service.accounts.Update(ctx, record); err != nil { switch { case errors.Is(err, ports.ErrNotFound): return UpdateMySettingsResult{}, shared.SubjectNotFound() case errors.Is(err, ports.ErrConflict): return UpdateMySettingsResult{}, shared.Conflict() default: return UpdateMySettingsResult{}, shared.ServiceUnavailable(err) } } updatedState, err := service.loader.Load(ctx, userID) if err != nil { return UpdateMySettingsResult{}, err } outcome = "updated" result = UpdateMySettingsResult{Account: updatedState.View()} service.publishSettingsChanged(ctx, updatedState.AccountRecord) return result, nil } func parseRaceName(value string) (common.RaceName, error) { return shared.ParseRaceName(value) } func parsePreferredLanguage(value string) (common.LanguageTag, error) { languageTag, err := shared.ParseLanguageTag(value) if err != nil { return "", reframeFieldError("preferred_language", "language tag", err) } return languageTag, nil } func parseTimeZoneName(value string) (common.TimeZoneName, error) { timeZoneName, err := shared.ParseTimeZoneName(value) if err != nil { return "", reframeFieldError("time_zone", "time zone name", err) } return timeZoneName, nil } func reframeFieldError(fieldName string, valueName string, err error) error { if err == nil { return nil } message := err.Error() prefix := valueName + " " if strings.HasPrefix(message, prefix) { message = fieldName + " " + strings.TrimPrefix(message, prefix) } else { message = fmt.Sprintf("%s: %s", fieldName, message) } return shared.InvalidRequest(message) } func (service *ProfileUpdater) publishProfileChanged(ctx context.Context, record account.UserAccount) { if service.profilePublisher == nil { return } event := ports.ProfileChangedEvent{ UserID: record.UserID, OccurredAt: record.UpdatedAt.UTC(), Source: gatewaySelfServiceSource, Operation: ports.ProfileChangedOperationUpdated, RaceName: record.RaceName, } if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil { if service.telemetry != nil { service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType) } shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err, "use_case", "update_my_profile", "user_id", record.UserID.String(), "source", gatewaySelfServiceSource.String(), ) } } func (service *SettingsUpdater) publishSettingsChanged(ctx context.Context, record account.UserAccount) { if service.settingsPublisher == nil { return } event := ports.SettingsChangedEvent{ UserID: record.UserID, OccurredAt: record.UpdatedAt.UTC(), Source: gatewaySelfServiceSource, Operation: ports.SettingsChangedOperationUpdated, PreferredLanguage: record.PreferredLanguage, TimeZone: record.TimeZone, } if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil { if service.telemetry != nil { service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType) } shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err, "use_case", "update_my_settings", "user_id", record.UserID.String(), "source", gatewaySelfServiceSource.String(), ) } }