198 lines
6.0 KiB
Go
198 lines
6.0 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.IsDeleted() {
|
|
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|