feat: backend service
This commit is contained in:
@@ -0,0 +1,757 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/model"
|
||||
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for the user package.
|
||||
// All queries are built through go-jet against the generated table
|
||||
// bindings under `backend/internal/postgres/jet/backend/table`.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// AccountRow mirrors a row in `backend.accounts` with the specific
|
||||
// projection the user-package read paths need. It is not a full
|
||||
// representation of the table; column subsets like the audit trail are
|
||||
// folded into Account by the Service layer.
|
||||
type AccountRow struct {
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
UserName string
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
DeclaredCountry string
|
||||
PermanentBlock bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
// accountInsert is the parameter struct for InsertAccountWithSnapshot.
|
||||
type accountInsert struct {
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
UserName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
DeclaredCountry string
|
||||
}
|
||||
|
||||
// settingsPatch carries the optional settings columns supplied by an
|
||||
// `UpdateSettingsInput`. Nil pointers mean "leave the column alone".
|
||||
type settingsPatch struct {
|
||||
PreferredLanguage *string
|
||||
TimeZone *string
|
||||
}
|
||||
|
||||
func (p settingsPatch) empty() bool {
|
||||
return p.PreferredLanguage == nil && p.TimeZone == nil
|
||||
}
|
||||
|
||||
// sanctionInsert is the parameter struct for ApplySanctionTx.
|
||||
type sanctionInsert struct {
|
||||
UserID uuid.UUID
|
||||
SanctionCode string
|
||||
Scope string
|
||||
ReasonCode string
|
||||
ActorType string
|
||||
ActorID string
|
||||
AppliedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
FlipPermanent bool
|
||||
}
|
||||
|
||||
// limitInsert is the parameter struct for ApplyLimitTx.
|
||||
type limitInsert struct {
|
||||
UserID uuid.UUID
|
||||
LimitCode string
|
||||
Value int32
|
||||
ReasonCode string
|
||||
ActorType string
|
||||
ActorID string
|
||||
AppliedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// errEmailRace is a sentinel returned by InsertAccountWithSnapshot when
|
||||
// the ON CONFLICT (email) DO NOTHING branch fires. The caller looks up
|
||||
// the existing user_id and returns it instead.
|
||||
var errEmailRace = errors.New("user store: email already exists")
|
||||
|
||||
// accountColumns is the canonical projection used by every read of
|
||||
// `backend.accounts`. Centralised so the model-row → AccountRow
|
||||
// converter stays in sync with the SELECT order.
|
||||
func accountColumns() postgres.ColumnList {
|
||||
a := table.Accounts
|
||||
return postgres.ColumnList{
|
||||
a.UserID, a.Email, a.UserName, a.DisplayName,
|
||||
a.PreferredLanguage, a.TimeZone, a.DeclaredCountry, a.PermanentBlock,
|
||||
a.CreatedAt, a.UpdatedAt, a.DeletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotColumns is the canonical projection used by every read of
|
||||
// `backend.entitlement_snapshots`.
|
||||
func snapshotColumns() postgres.ColumnList {
|
||||
s := table.EntitlementSnapshots
|
||||
return postgres.ColumnList{
|
||||
s.UserID, s.Tier, s.IsPaid, s.Source, s.ActorType, s.ActorID,
|
||||
s.ReasonCode, s.StartsAt, s.EndsAt, s.MaxRegisteredRaceNames, s.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// LookupAccountIDByEmail returns the user_id of the live account for
|
||||
// email. The boolean reports whether a row was found. Soft-deleted
|
||||
// rows are skipped.
|
||||
func (s *Store) LookupAccountIDByEmail(ctx context.Context, email string) (uuid.UUID, bool, error) {
|
||||
stmt := postgres.SELECT(table.Accounts.UserID).
|
||||
FROM(table.Accounts).
|
||||
WHERE(
|
||||
table.Accounts.Email.EQ(postgres.String(email)).
|
||||
AND(table.Accounts.DeletedAt.IS_NULL()),
|
||||
).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return row.UserID, true, nil
|
||||
}
|
||||
|
||||
// LookupAccount returns the AccountRow projection for userID. Soft-deleted
|
||||
// rows are excluded; returns ErrAccountNotFound when no live row exists.
|
||||
func (s *Store) LookupAccount(ctx context.Context, userID uuid.UUID) (AccountRow, error) {
|
||||
stmt := postgres.SELECT(accountColumns()).
|
||||
FROM(table.Accounts).
|
||||
WHERE(
|
||||
table.Accounts.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(table.Accounts.DeletedAt.IS_NULL()),
|
||||
).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return AccountRow{}, ErrAccountNotFound
|
||||
}
|
||||
return AccountRow{}, fmt.Errorf("user store: scan account: %w", err)
|
||||
}
|
||||
return modelToAccountRow(row), nil
|
||||
}
|
||||
|
||||
// ListAccountRows returns the requested page of live accounts together
|
||||
// with the total live-row count for pagination.
|
||||
func (s *Store) ListAccountRows(ctx context.Context, page, pageSize int) ([]AccountRow, int, error) {
|
||||
a := table.Accounts
|
||||
totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
|
||||
FROM(a).
|
||||
WHERE(a.DeletedAt.IS_NULL())
|
||||
var totalDest struct {
|
||||
Count int64 `alias:"count"`
|
||||
}
|
||||
if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil {
|
||||
return nil, 0, fmt.Errorf("user store: count accounts: %w", err)
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
listStmt := postgres.SELECT(accountColumns()).
|
||||
FROM(a).
|
||||
WHERE(a.DeletedAt.IS_NULL()).
|
||||
ORDER_BY(a.CreatedAt.DESC(), a.UserID.DESC()).
|
||||
LIMIT(int64(pageSize)).OFFSET(int64(offset))
|
||||
|
||||
var rows []model.Accounts
|
||||
if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, 0, fmt.Errorf("user store: list accounts: %w", err)
|
||||
}
|
||||
out := make([]AccountRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, modelToAccountRow(row))
|
||||
}
|
||||
return out, int(totalDest.Count), nil
|
||||
}
|
||||
|
||||
// InsertAccountWithSnapshot persists a brand-new accounts row and the
|
||||
// matching default entitlement snapshot in one transaction. On
|
||||
// ON CONFLICT (email) DO NOTHING it returns errEmailRace so the caller
|
||||
// can recover the existing user_id; on user_name UNIQUE violation it
|
||||
// returns the underlying pgconn error so the caller can retry the
|
||||
// suffix.
|
||||
func (s *Store) InsertAccountWithSnapshot(ctx context.Context, account accountInsert, snapshot EntitlementSnapshot) (uuid.UUID, error) {
|
||||
var declaredCountryArg postgres.Expression = postgres.StringExp(postgres.NULL)
|
||||
if account.DeclaredCountry != "" {
|
||||
declaredCountryArg = postgres.String(account.DeclaredCountry)
|
||||
}
|
||||
var insertedID uuid.UUID
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
insertStmt := table.Accounts.INSERT(
|
||||
table.Accounts.UserID, table.Accounts.Email, table.Accounts.UserName,
|
||||
table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.DeclaredCountry,
|
||||
).VALUES(
|
||||
account.UserID, account.Email, account.UserName,
|
||||
account.PreferredLanguage, account.TimeZone, declaredCountryArg,
|
||||
).
|
||||
ON_CONFLICT(table.Accounts.Email).DO_NOTHING().
|
||||
RETURNING(table.Accounts.UserID)
|
||||
|
||||
var inserted model.Accounts
|
||||
if err := insertStmt.QueryContext(ctx, tx, &inserted); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return errEmailRace
|
||||
}
|
||||
return err
|
||||
}
|
||||
insertedID = inserted.UserID
|
||||
return insertSnapshotTx(ctx, tx, snapshot)
|
||||
})
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return insertedID, nil
|
||||
}
|
||||
|
||||
// LookupEntitlementSnapshot loads the snapshot row for userID. Returns
|
||||
// ErrAccountNotFound when no row exists (a fresh account without a
|
||||
// snapshot is treated as "account not found" — the bootstrap path
|
||||
// always inserts the default snapshot).
|
||||
func (s *Store) LookupEntitlementSnapshot(ctx context.Context, userID uuid.UUID) (EntitlementSnapshot, error) {
|
||||
stmt := postgres.SELECT(snapshotColumns()).
|
||||
FROM(table.EntitlementSnapshots).
|
||||
WHERE(table.EntitlementSnapshots.UserID.EQ(postgres.UUID(userID))).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.EntitlementSnapshots
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return EntitlementSnapshot{}, ErrAccountNotFound
|
||||
}
|
||||
return EntitlementSnapshot{}, fmt.Errorf("user store: lookup snapshot for %s: %w", userID, err)
|
||||
}
|
||||
return modelToSnapshot(row), nil
|
||||
}
|
||||
|
||||
// ListEntitlementSnapshots loads every snapshot row. Cache.Warm calls
|
||||
// this at process boot.
|
||||
func (s *Store) ListEntitlementSnapshots(ctx context.Context) ([]EntitlementSnapshot, error) {
|
||||
stmt := postgres.SELECT(snapshotColumns()).FROM(table.EntitlementSnapshots)
|
||||
var rows []model.EntitlementSnapshots
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("user store: list snapshots: %w", err)
|
||||
}
|
||||
out := make([]EntitlementSnapshot, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, modelToSnapshot(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListActiveSanctions returns the active sanctions for userID joined
|
||||
// with the audit columns from the underlying records row. Order is
|
||||
// applied_at DESC so the most recent sanction surfaces first.
|
||||
func (s *Store) ListActiveSanctions(ctx context.Context, userID uuid.UUID) ([]ActiveSanction, error) {
|
||||
a := table.SanctionActive
|
||||
r := table.SanctionRecords
|
||||
stmt := postgres.SELECT(
|
||||
r.SanctionCode, r.Scope, r.ReasonCode,
|
||||
r.ActorType, r.ActorID,
|
||||
r.AppliedAt, r.ExpiresAt,
|
||||
).
|
||||
FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))).
|
||||
WHERE(a.UserID.EQ(postgres.UUID(userID))).
|
||||
ORDER_BY(r.AppliedAt.DESC())
|
||||
|
||||
var rows []model.SanctionRecords
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("user store: list active sanctions: %w", err)
|
||||
}
|
||||
out := make([]ActiveSanction, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
entry := ActiveSanction{
|
||||
SanctionCode: row.SanctionCode,
|
||||
Scope: row.Scope,
|
||||
ReasonCode: row.ReasonCode,
|
||||
Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)},
|
||||
AppliedAt: row.AppliedAt,
|
||||
}
|
||||
if row.ExpiresAt != nil {
|
||||
t := *row.ExpiresAt
|
||||
entry.ExpiresAt = &t
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListActiveLimits returns the active limits for userID joined with
|
||||
// the audit columns from the underlying records row.
|
||||
func (s *Store) ListActiveLimits(ctx context.Context, userID uuid.UUID) ([]ActiveLimit, error) {
|
||||
a := table.LimitActive
|
||||
r := table.LimitRecords
|
||||
stmt := postgres.SELECT(
|
||||
r.LimitCode, a.Value, r.ReasonCode,
|
||||
r.ActorType, r.ActorID,
|
||||
r.AppliedAt, r.ExpiresAt,
|
||||
).
|
||||
FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))).
|
||||
WHERE(a.UserID.EQ(postgres.UUID(userID))).
|
||||
ORDER_BY(r.AppliedAt.DESC())
|
||||
|
||||
var rows []struct {
|
||||
LimitRecords model.LimitRecords
|
||||
LimitActive model.LimitActive
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("user store: list active limits: %w", err)
|
||||
}
|
||||
out := make([]ActiveLimit, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
entry := ActiveLimit{
|
||||
LimitCode: row.LimitRecords.LimitCode,
|
||||
Value: row.LimitActive.Value,
|
||||
ReasonCode: row.LimitRecords.ReasonCode,
|
||||
Actor: ActorRef{Type: row.LimitRecords.ActorType, ID: derefString(row.LimitRecords.ActorID)},
|
||||
AppliedAt: row.LimitRecords.AppliedAt,
|
||||
}
|
||||
if row.LimitRecords.ExpiresAt != nil {
|
||||
t := *row.LimitRecords.ExpiresAt
|
||||
entry.ExpiresAt = &t
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateAccountDisplayName patches accounts.display_name and bumps
|
||||
// updated_at. Returns ErrAccountNotFound when no live row matches.
|
||||
func (s *Store) UpdateAccountDisplayName(ctx context.Context, userID uuid.UUID, displayName string, now time.Time) error {
|
||||
a := table.Accounts
|
||||
stmt := a.UPDATE(a.DisplayName, a.UpdatedAt).
|
||||
SET(displayName, now).
|
||||
WHERE(
|
||||
a.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(a.DeletedAt.IS_NULL()),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user store: update display_name: %w", err)
|
||||
}
|
||||
return rowsAffectedOrNotFound(res)
|
||||
}
|
||||
|
||||
// UpdateAccountSettings patches the supplied settings columns and bumps
|
||||
// updated_at. Empty patches are a precondition error from the caller.
|
||||
func (s *Store) UpdateAccountSettings(ctx context.Context, userID uuid.UUID, patch settingsPatch, now time.Time) error {
|
||||
if patch.empty() {
|
||||
return fmt.Errorf("user store: update settings: empty patch")
|
||||
}
|
||||
a := table.Accounts
|
||||
rest := make([]any, 0, 2)
|
||||
if patch.PreferredLanguage != nil {
|
||||
rest = append(rest, a.PreferredLanguage.SET(postgres.String(*patch.PreferredLanguage)))
|
||||
}
|
||||
if patch.TimeZone != nil {
|
||||
rest = append(rest, a.TimeZone.SET(postgres.String(*patch.TimeZone)))
|
||||
}
|
||||
stmt := a.UPDATE().
|
||||
SET(a.UpdatedAt.SET(postgres.TimestampzT(now)), rest...).
|
||||
WHERE(
|
||||
a.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(a.DeletedAt.IS_NULL()),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user store: update settings: %w", err)
|
||||
}
|
||||
return rowsAffectedOrNotFound(res)
|
||||
}
|
||||
|
||||
// ApplyEntitlementTx persists a fresh entitlement_records row and
|
||||
// upserts the matching entitlement_snapshots row in one transaction.
|
||||
// Returns the persisted snapshot exactly as stored (created_at is the
|
||||
// input UpdatedAt, etc.).
|
||||
func (s *Store) ApplyEntitlementTx(ctx context.Context, snap EntitlementSnapshot) (EntitlementSnapshot, error) {
|
||||
if err := s.assertAccountLive(ctx, snap.UserID); err != nil {
|
||||
return EntitlementSnapshot{}, err
|
||||
}
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
}
|
||||
recordStmt := table.EntitlementRecords.INSERT(
|
||||
table.EntitlementRecords.RecordID,
|
||||
table.EntitlementRecords.UserID,
|
||||
table.EntitlementRecords.Tier,
|
||||
table.EntitlementRecords.IsPaid,
|
||||
table.EntitlementRecords.Source,
|
||||
table.EntitlementRecords.ActorType,
|
||||
table.EntitlementRecords.ActorID,
|
||||
table.EntitlementRecords.ReasonCode,
|
||||
table.EntitlementRecords.StartsAt,
|
||||
table.EntitlementRecords.EndsAt,
|
||||
table.EntitlementRecords.CreatedAt,
|
||||
).VALUES(
|
||||
recordID, snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
||||
snap.Actor.Type, actorID, snap.ReasonCode,
|
||||
snap.StartsAt, endsAt, snap.UpdatedAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert entitlement record: %w", err)
|
||||
}
|
||||
return upsertSnapshotTx(ctx, tx, snap)
|
||||
})
|
||||
if err != nil {
|
||||
return EntitlementSnapshot{}, err
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// ApplySanctionTx persists a fresh sanction_records row, upserts
|
||||
// sanction_active, and (when alsoFlipPermanent is set) flips
|
||||
// accounts.permanent_block to true — all in one transaction.
|
||||
func (s *Store) ApplySanctionTx(ctx context.Context, input sanctionInsert) error {
|
||||
if err := s.assertAccountLive(ctx, input.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(input.ActorID)
|
||||
var expiresAt any
|
||||
if input.ExpiresAt != nil {
|
||||
expiresAt = *input.ExpiresAt
|
||||
}
|
||||
recordStmt := table.SanctionRecords.INSERT(
|
||||
table.SanctionRecords.RecordID,
|
||||
table.SanctionRecords.UserID,
|
||||
table.SanctionRecords.SanctionCode,
|
||||
table.SanctionRecords.Scope,
|
||||
table.SanctionRecords.ReasonCode,
|
||||
table.SanctionRecords.ActorType,
|
||||
table.SanctionRecords.ActorID,
|
||||
table.SanctionRecords.AppliedAt,
|
||||
table.SanctionRecords.ExpiresAt,
|
||||
).VALUES(
|
||||
recordID, input.UserID, input.SanctionCode, input.Scope, input.ReasonCode,
|
||||
input.ActorType, actorID, input.AppliedAt, expiresAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert sanction record: %w", err)
|
||||
}
|
||||
|
||||
sa := table.SanctionActive
|
||||
activeStmt := sa.INSERT(sa.UserID, sa.SanctionCode, sa.RecordID).
|
||||
VALUES(input.UserID, input.SanctionCode, recordID).
|
||||
ON_CONFLICT(sa.UserID, sa.SanctionCode).
|
||||
DO_UPDATE(postgres.SET(
|
||||
sa.RecordID.SET(sa.EXCLUDED.RecordID),
|
||||
))
|
||||
if _, err := activeStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("upsert sanction_active: %w", err)
|
||||
}
|
||||
|
||||
if input.FlipPermanent {
|
||||
a := table.Accounts
|
||||
permStmt := a.UPDATE().
|
||||
SET(
|
||||
a.PermanentBlock.SET(postgres.Bool(true)),
|
||||
a.UpdatedAt.SET(postgres.TimestampzT(input.AppliedAt)),
|
||||
).
|
||||
WHERE(
|
||||
a.UserID.EQ(postgres.UUID(input.UserID)).
|
||||
AND(a.DeletedAt.IS_NULL()),
|
||||
)
|
||||
if _, err := permStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("flip permanent_block: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyLimitTx persists a fresh limit_records row and upserts
|
||||
// limit_active in one transaction.
|
||||
func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error {
|
||||
if err := s.assertAccountLive(ctx, input.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(input.ActorID)
|
||||
var expiresAt any
|
||||
if input.ExpiresAt != nil {
|
||||
expiresAt = *input.ExpiresAt
|
||||
}
|
||||
recordStmt := table.LimitRecords.INSERT(
|
||||
table.LimitRecords.RecordID,
|
||||
table.LimitRecords.UserID,
|
||||
table.LimitRecords.LimitCode,
|
||||
table.LimitRecords.Value,
|
||||
table.LimitRecords.ReasonCode,
|
||||
table.LimitRecords.ActorType,
|
||||
table.LimitRecords.ActorID,
|
||||
table.LimitRecords.AppliedAt,
|
||||
table.LimitRecords.ExpiresAt,
|
||||
).VALUES(
|
||||
recordID, input.UserID, input.LimitCode, input.Value, input.ReasonCode,
|
||||
input.ActorType, actorID, input.AppliedAt, expiresAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert limit record: %w", err)
|
||||
}
|
||||
|
||||
la := table.LimitActive
|
||||
activeStmt := la.INSERT(la.UserID, la.LimitCode, la.RecordID, la.Value).
|
||||
VALUES(input.UserID, input.LimitCode, recordID, input.Value).
|
||||
ON_CONFLICT(la.UserID, la.LimitCode).
|
||||
DO_UPDATE(postgres.SET(
|
||||
la.RecordID.SET(la.EXCLUDED.RecordID),
|
||||
la.Value.SET(la.EXCLUDED.Value),
|
||||
))
|
||||
if _, err := activeStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("upsert limit_active: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SoftDeleteAccount marks the account soft-deleted with the supplied
|
||||
// actor trail. The boolean reports whether the row actually changed
|
||||
// (true for a fresh delete; false when the row was already
|
||||
// soft-deleted or does not exist). The caller distinguishes "already
|
||||
// gone" from "never existed" by reading the row separately when it
|
||||
// matters; for the cascade orchestration "no change" is treated as a
|
||||
// successful idempotent operation.
|
||||
func (s *Store) SoftDeleteAccount(ctx context.Context, userID uuid.UUID, actor ActorRef, now time.Time) (bool, error) {
|
||||
a := table.Accounts
|
||||
actorIDExpr := nullableStringExpr(actor.ID)
|
||||
stmt := a.UPDATE().
|
||||
SET(
|
||||
a.DeletedAt.SET(postgres.TimestampzT(now)),
|
||||
a.DeletedActorType.SET(postgres.String(actor.Type)),
|
||||
a.DeletedActorID.SET(actorIDExpr),
|
||||
a.UpdatedAt.SET(postgres.TimestampzT(now)),
|
||||
).
|
||||
WHERE(
|
||||
a.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(a.DeletedAt.IS_NULL()),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("user store: soft delete %s: %w", userID, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("user store: soft delete rows-affected: %w", err)
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
// assertAccountLive returns ErrAccountNotFound when userID does not
|
||||
// match a live accounts row. Used by the mutation paths to fail fast
|
||||
// before opening a transaction.
|
||||
func (s *Store) assertAccountLive(ctx context.Context, userID uuid.UUID) error {
|
||||
a := table.Accounts
|
||||
stmt := postgres.SELECT(a.UserID).
|
||||
FROM(a).
|
||||
WHERE(
|
||||
a.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(a.DeletedAt.IS_NULL()),
|
||||
).
|
||||
LIMIT(1)
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return ErrAccountNotFound
|
||||
}
|
||||
return fmt.Errorf("user store: account live-check: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error {
|
||||
es := table.EntitlementSnapshots
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
}
|
||||
stmt := es.INSERT(
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID,
|
||||
es.ReasonCode, es.StartsAt, es.EndsAt,
|
||||
es.MaxRegisteredRaceNames, es.UpdatedAt,
|
||||
).VALUES(
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID,
|
||||
snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt,
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert entitlement_snapshots: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error {
|
||||
es := table.EntitlementSnapshots
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
}
|
||||
stmt := es.INSERT(
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID,
|
||||
es.ReasonCode, es.StartsAt, es.EndsAt,
|
||||
es.MaxRegisteredRaceNames, es.UpdatedAt,
|
||||
).VALUES(
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID,
|
||||
snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt,
|
||||
).
|
||||
ON_CONFLICT(es.UserID).
|
||||
DO_UPDATE(postgres.SET(
|
||||
es.Tier.SET(es.EXCLUDED.Tier),
|
||||
es.IsPaid.SET(es.EXCLUDED.IsPaid),
|
||||
es.Source.SET(es.EXCLUDED.Source),
|
||||
es.ActorType.SET(es.EXCLUDED.ActorType),
|
||||
es.ActorID.SET(es.EXCLUDED.ActorID),
|
||||
es.ReasonCode.SET(es.EXCLUDED.ReasonCode),
|
||||
es.StartsAt.SET(es.EXCLUDED.StartsAt),
|
||||
es.EndsAt.SET(es.EXCLUDED.EndsAt),
|
||||
es.MaxRegisteredRaceNames.SET(es.EXCLUDED.MaxRegisteredRaceNames),
|
||||
es.UpdatedAt.SET(es.EXCLUDED.UpdatedAt),
|
||||
))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("upsert entitlement_snapshots: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modelToAccountRow projects a generated model row onto the public
|
||||
// AccountRow struct. The DeclaredCountry field is collapsed from
|
||||
// nullable to "" by the projection.
|
||||
func modelToAccountRow(row model.Accounts) AccountRow {
|
||||
out := AccountRow{
|
||||
UserID: row.UserID,
|
||||
Email: row.Email,
|
||||
UserName: row.UserName,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
TimeZone: row.TimeZone,
|
||||
PermanentBlock: row.PermanentBlock,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
if row.DeclaredCountry != nil {
|
||||
out.DeclaredCountry = *row.DeclaredCountry
|
||||
}
|
||||
if row.DeletedAt != nil {
|
||||
t := *row.DeletedAt
|
||||
out.DeletedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// modelToSnapshot projects a generated model row onto the public
|
||||
// EntitlementSnapshot struct.
|
||||
func modelToSnapshot(row model.EntitlementSnapshots) EntitlementSnapshot {
|
||||
out := EntitlementSnapshot{
|
||||
UserID: row.UserID,
|
||||
Tier: row.Tier,
|
||||
IsPaid: row.IsPaid,
|
||||
Source: row.Source,
|
||||
Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)},
|
||||
ReasonCode: row.ReasonCode,
|
||||
StartsAt: row.StartsAt,
|
||||
MaxRegisteredRaceNames: row.MaxRegisteredRaceNames,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
if row.EndsAt != nil {
|
||||
t := *row.EndsAt
|
||||
out.EndsAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nullableString converts a Go string to the `any` form expected by jet
|
||||
// VALUES: an empty string becomes nil so the column receives NULL.
|
||||
func nullableString(v string) any {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// nullableStringExpr returns a typed jet expression: the empty string
|
||||
// produces NULL, otherwise a String literal. Used by UPDATE SET paths
|
||||
// where jet's SET wants a typed Expression rather than `any`.
|
||||
func nullableStringExpr(v string) postgres.StringExpression {
|
||||
if v == "" {
|
||||
return postgres.StringExp(postgres.NULL)
|
||||
}
|
||||
return postgres.String(v)
|
||||
}
|
||||
|
||||
// derefString returns the empty string when p is nil, otherwise *p.
|
||||
func derefString(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// rowsAffectedOrNotFound returns ErrAccountNotFound when the UPDATE
|
||||
// affected zero rows, nil otherwise. Used by the account-mutation paths
|
||||
// that need fail-fast on a missing/soft-deleted target.
|
||||
func rowsAffectedOrNotFound(res sql.Result) error {
|
||||
if res == nil {
|
||||
return nil
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("user store: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrAccountNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withTx wraps fn in a Postgres transaction. fn's return value
|
||||
// determines commit (nil) vs rollback (non-nil). Rollback errors are
|
||||
// swallowed when fn already returned an error, since the latter is
|
||||
// more actionable.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user store: begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("user store: commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user