Files
galaxy-game/user/internal/service/geosync/service.go
T
2026-04-10 19:05:02 +02:00

195 lines
5.9 KiB
Go

// 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(),
}
}