// Package geosync implements the trusted geo-facing declared-country sync // command owned by User Service. package geosync import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/ports" "galaxy/user/internal/service/shared" "galaxy/user/internal/telemetry" "golang.org/x/text/language" ) const geoProfileServiceSource = common.Source("geo_profile_service") // SyncDeclaredCountryInput stores one trusted geo-facing country-sync request. type SyncDeclaredCountryInput struct { // UserID identifies the regular user whose current declared country must be // synchronized. UserID string // DeclaredCountry stores the new current effective declared country. DeclaredCountry string } // SyncDeclaredCountryResult stores one trusted geo-facing country-sync result. type SyncDeclaredCountryResult struct { // UserID identifies the synchronized user. UserID string `json:"user_id"` // DeclaredCountry stores the current effective declared country after the // command completes. DeclaredCountry string `json:"declared_country"` // UpdatedAt stores the effective account mutation timestamp. Same-value // no-op syncs return the current stored timestamp unchanged. UpdatedAt time.Time `json:"updated_at"` } // SyncService executes the trusted geo-facing declared-country sync command. type SyncService struct { accounts ports.UserAccountStore clock ports.Clock publisher ports.DeclaredCountryChangedPublisher logger *slog.Logger telemetry *telemetry.Runtime } // NewSyncService constructs one trusted declared-country sync command. func NewSyncService( accounts ports.UserAccountStore, clock ports.Clock, publisher ports.DeclaredCountryChangedPublisher, ) (*SyncService, error) { return NewSyncServiceWithObservability(accounts, clock, publisher, nil, nil) } // NewSyncServiceWithObservability constructs one trusted declared-country sync // command with optional structured logging and event-publication metrics. func NewSyncServiceWithObservability( accounts ports.UserAccountStore, clock ports.Clock, publisher ports.DeclaredCountryChangedPublisher, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, ) (*SyncService, error) { switch { case accounts == nil: return nil, fmt.Errorf("geo declared-country sync service: user account store must not be nil") case clock == nil: return nil, fmt.Errorf("geo declared-country sync service: clock must not be nil") case publisher == nil: return nil, fmt.Errorf("geo declared-country sync service: declared-country changed publisher must not be nil") default: return &SyncService{ accounts: accounts, clock: clock, publisher: publisher, logger: logger, telemetry: telemetryRuntime, }, nil } } // Execute synchronizes the current effective declared country of one user. func (service *SyncService) Execute( ctx context.Context, input SyncDeclaredCountryInput, ) (result SyncDeclaredCountryResult, err error) { outcome := "failed" userIDString := "" defer func() { shared.LogServiceOutcome(service.logger, ctx, "declared-country sync completed", err, "use_case", "sync_declared_country", "outcome", outcome, "user_id", userIDString, "source", geoProfileServiceSource.String(), ) }() if ctx == nil { return SyncDeclaredCountryResult{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { return SyncDeclaredCountryResult{}, err } userIDString = userID.String() declaredCountry, err := parseDeclaredCountry(input.DeclaredCountry) if err != nil { return SyncDeclaredCountryResult{}, err } record, err := service.accounts.GetByUserID(ctx, userID) switch { case err == nil: case errors.Is(err, ports.ErrNotFound): return SyncDeclaredCountryResult{}, shared.SubjectNotFound() default: return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err) } if record.DeclaredCountry == declaredCountry { outcome = "noop" return resultFromAccount(record), nil } record.DeclaredCountry = declaredCountry record.UpdatedAt = service.clock.Now().UTC() if err := service.accounts.Update(ctx, record); err != nil { switch { case errors.Is(err, ports.ErrNotFound): return SyncDeclaredCountryResult{}, shared.SubjectNotFound() case errors.Is(err, ports.ErrConflict): return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err) default: return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err) } } result = resultFromAccount(record) outcome = "updated" if err := service.publisher.PublishDeclaredCountryChanged(ctx, ports.DeclaredCountryChangedEvent{ UserID: record.UserID, DeclaredCountry: record.DeclaredCountry, UpdatedAt: record.UpdatedAt, Source: geoProfileServiceSource, }); err != nil { if service.telemetry != nil { service.telemetry.RecordEventPublicationFailure(ctx, ports.DeclaredCountryChangedEventType) } shared.LogEventPublicationFailure(service.logger, ctx, ports.DeclaredCountryChangedEventType, err, "use_case", "sync_declared_country", "user_id", record.UserID.String(), "source", geoProfileServiceSource.String(), ) } return result, nil } func parseDeclaredCountry(value string) (common.CountryCode, error) { const message = "declared_country must be a valid ISO 3166-1 alpha-2 country code" code := common.CountryCode(shared.NormalizeString(value)) if err := code.Validate(); err != nil { return "", shared.InvalidRequest(message) } region, err := language.ParseRegion(code.String()) if err != nil || !region.IsCountry() || region.Canonicalize().String() != code.String() { return "", shared.InvalidRequest(message) } return code, nil } func resultFromAccount(record account.UserAccount) SyncDeclaredCountryResult { return SyncDeclaredCountryResult{ UserID: record.UserID.String(), DeclaredCountry: record.DeclaredCountry.String(), UpdatedAt: record.UpdatedAt.UTC(), } }