package server import ( "context" "errors" "net/http" "time" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/telemetry" "galaxy/backend/internal/user" "github.com/gin-gonic/gin" "go.uber.org/zap" ) // timestampLayout matches the format used by other backend handlers // (The implementation deviceSession serialisation). UTC, millisecond precision. const timestampLayout = "2006-01-02T15:04:05.000Z07:00" // respondAccountError maps user-package sentinels to the standard // JSON error envelope. Unknown errors land on a 500. func respondAccountError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) { switch { case errors.Is(err, user.ErrAccountNotFound): httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found") case errors.Is(err, user.ErrInvalidInput), errors.Is(err, user.ErrInvalidActor): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error()) case errors.Is(err, user.ErrInvalidTier): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "tier is not supported") case errors.Is(err, user.ErrInvalidSanctionCode): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "sanction_code is not supported") default: logger.Error(op+" failed", append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., ) httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error") } } // accountResponseToWire renders the Account aggregate into the // `AccountResponse` shape declared in openapi.yaml. func accountResponseToWire(account user.Account) accountResponseWire { return accountResponseWire{Account: accountToWire(account)} } func accountToWire(account user.Account) accountWire { out := accountWire{ UserID: account.UserID.String(), Email: account.Email, UserName: account.UserName, DisplayName: account.DisplayName, PreferredLanguage: account.PreferredLanguage, TimeZone: account.TimeZone, DeclaredCountry: account.DeclaredCountry, Entitlement: entitlementSnapshotToWire(account.Entitlement), ActiveSanctions: activeSanctionsToWire(account.ActiveSanctions), ActiveLimits: activeLimitsToWire(account.ActiveLimits), CreatedAt: account.CreatedAt.UTC().Format(timestampLayout), UpdatedAt: account.UpdatedAt.UTC().Format(timestampLayout), } return out } func entitlementSnapshotToWire(snap user.EntitlementSnapshot) entitlementSnapshotWire { out := entitlementSnapshotWire{ PlanCode: snap.Tier, IsPaid: snap.IsPaid, Source: snap.Source, Actor: actorRefToWire(snap.Actor), ReasonCode: snap.ReasonCode, StartsAt: snap.StartsAt.UTC().Format(timestampLayout), MaxRegisteredRaceNames: snap.MaxRegisteredRaceNames, UpdatedAt: snap.UpdatedAt.UTC().Format(timestampLayout), } if snap.EndsAt != nil { formatted := snap.EndsAt.UTC().Format(timestampLayout) out.EndsAt = &formatted } return out } func activeSanctionsToWire(items []user.ActiveSanction) []activeSanctionWire { out := make([]activeSanctionWire, 0, len(items)) for _, s := range items { entry := activeSanctionWire{ SanctionCode: s.SanctionCode, Scope: s.Scope, ReasonCode: s.ReasonCode, Actor: actorRefToWire(s.Actor), AppliedAt: s.AppliedAt.UTC().Format(timestampLayout), } if s.ExpiresAt != nil { formatted := s.ExpiresAt.UTC().Format(timestampLayout) entry.ExpiresAt = &formatted } out = append(out, entry) } return out } func activeLimitsToWire(items []user.ActiveLimit) []activeLimitWire { out := make([]activeLimitWire, 0, len(items)) for _, l := range items { entry := activeLimitWire{ LimitCode: l.LimitCode, Value: l.Value, ReasonCode: l.ReasonCode, Actor: actorRefToWire(l.Actor), AppliedAt: l.AppliedAt.UTC().Format(timestampLayout), } if l.ExpiresAt != nil { formatted := l.ExpiresAt.UTC().Format(timestampLayout) entry.ExpiresAt = &formatted } out = append(out, entry) } return out } func actorRefToWire(actor user.ActorRef) actorRefWire { return actorRefWire{Type: actor.Type, ID: actor.ID} } // parseTimePtr converts a wire timestamp pointer into a time.Time // pointer. A nil or empty input yields nil. Invalid timestamps return // an error that handlers map to ErrInvalidInput. func parseTimePtr(raw *string) (*time.Time, error) { if raw == nil { return nil, nil } if *raw == "" { return nil, nil } t, err := time.Parse(time.RFC3339Nano, *raw) if err != nil { return nil, err } return &t, nil } // accountResponseWire mirrors `AccountResponse` in openapi.yaml. type accountResponseWire struct { Account accountWire `json:"account"` } // accountWire mirrors `Account` in openapi.yaml. type accountWire struct { UserID string `json:"user_id"` Email string `json:"email"` UserName string `json:"user_name"` DisplayName string `json:"display_name,omitempty"` PreferredLanguage string `json:"preferred_language"` TimeZone string `json:"time_zone"` DeclaredCountry string `json:"declared_country,omitempty"` Entitlement entitlementSnapshotWire `json:"entitlement"` ActiveSanctions []activeSanctionWire `json:"active_sanctions"` ActiveLimits []activeLimitWire `json:"active_limits"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type entitlementSnapshotWire struct { PlanCode string `json:"plan_code"` IsPaid bool `json:"is_paid"` Source string `json:"source"` Actor actorRefWire `json:"actor"` ReasonCode string `json:"reason_code"` StartsAt string `json:"starts_at"` EndsAt *string `json:"ends_at,omitempty"` MaxRegisteredRaceNames int32 `json:"max_registered_race_names"` UpdatedAt string `json:"updated_at"` } type activeSanctionWire struct { SanctionCode string `json:"sanction_code"` Scope string `json:"scope"` ReasonCode string `json:"reason_code"` Actor actorRefWire `json:"actor"` AppliedAt string `json:"applied_at"` ExpiresAt *string `json:"expires_at,omitempty"` } type activeLimitWire struct { LimitCode string `json:"limit_code"` Value int32 `json:"value"` ReasonCode string `json:"reason_code"` Actor actorRefWire `json:"actor"` AppliedAt string `json:"applied_at"` ExpiresAt *string `json:"expires_at,omitempty"` } type actorRefWire struct { Type string `json:"type"` ID string `json:"id,omitempty"` }