468 lines
15 KiB
Go
468 lines
15 KiB
Go
// 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(),
|
|
)
|
|
}
|
|
}
|