Files
2026-05-06 10:14:55 +03:00

198 lines
6.1 KiB
Go

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"`
}