817 lines
26 KiB
Go
817 lines
26 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"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
|
|
Actor ActorRef
|
|
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
|
|
Actor ActorRef
|
|
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.ActorUserID, s.ActorUsername,
|
|
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.ActorUserID, r.ActorUsername,
|
|
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: actorFromColumns(row.ActorType, row.ActorUserID, row.ActorUsername),
|
|
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.ActorUserID, r.ActorUsername,
|
|
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: actorFromColumns(row.LimitRecords.ActorType, row.LimitRecords.ActorUserID, row.LimitRecords.ActorUsername),
|
|
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
|
|
}
|
|
actorUserID, actorUsername, err := actorToColumnArgs(snap.Actor)
|
|
if err != nil {
|
|
return EntitlementSnapshot{}, err
|
|
}
|
|
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
recordID := uuid.New()
|
|
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.ActorUserID,
|
|
table.EntitlementRecords.ActorUsername,
|
|
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, actorUserID, actorUsername, 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, actorUserID, actorUsername)
|
|
})
|
|
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
|
|
}
|
|
actorUserID, actorUsername, err := actorToColumnArgs(input.Actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
recordID := uuid.New()
|
|
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.ActorUserID,
|
|
table.SanctionRecords.ActorUsername,
|
|
table.SanctionRecords.AppliedAt,
|
|
table.SanctionRecords.ExpiresAt,
|
|
).VALUES(
|
|
recordID, input.UserID, input.SanctionCode, input.Scope, input.ReasonCode,
|
|
input.Actor.Type, actorUserID, actorUsername, 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
|
|
}
|
|
actorUserID, actorUsername, err := actorToColumnArgs(input.Actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
recordID := uuid.New()
|
|
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.ActorUserID,
|
|
table.LimitRecords.ActorUsername,
|
|
table.LimitRecords.AppliedAt,
|
|
table.LimitRecords.ExpiresAt,
|
|
).VALUES(
|
|
recordID, input.UserID, input.LimitCode, input.Value, input.ReasonCode,
|
|
input.Actor.Type, actorUserID, actorUsername, 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
|
|
actorUserIDExpr, actorUsernameExpr, err := actorToColumnExprs(actor)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
stmt := a.UPDATE().
|
|
SET(
|
|
a.DeletedAt.SET(postgres.TimestampzT(now)),
|
|
a.DeletedActorType.SET(postgres.String(actor.Type)),
|
|
a.DeletedActorUserID.SET(actorUserIDExpr),
|
|
a.DeletedActorUsername.SET(actorUsernameExpr),
|
|
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 {
|
|
actorUserID, actorUsername, err := actorToColumnArgs(snap.Actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
es := table.EntitlementSnapshots
|
|
var endsAt any
|
|
if snap.EndsAt != nil {
|
|
endsAt = *snap.EndsAt
|
|
}
|
|
stmt := es.INSERT(
|
|
es.UserID, es.Tier, es.IsPaid, es.Source,
|
|
es.ActorType, es.ActorUserID, es.ActorUsername,
|
|
es.ReasonCode, es.StartsAt, es.EndsAt,
|
|
es.MaxRegisteredRaceNames, es.UpdatedAt,
|
|
).VALUES(
|
|
snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
|
snap.Actor.Type, actorUserID, actorUsername,
|
|
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, actorUserID, actorUsername any) error {
|
|
es := table.EntitlementSnapshots
|
|
var endsAt any
|
|
if snap.EndsAt != nil {
|
|
endsAt = *snap.EndsAt
|
|
}
|
|
stmt := es.INSERT(
|
|
es.UserID, es.Tier, es.IsPaid, es.Source,
|
|
es.ActorType, es.ActorUserID, es.ActorUsername,
|
|
es.ReasonCode, es.StartsAt, es.EndsAt,
|
|
es.MaxRegisteredRaceNames, es.UpdatedAt,
|
|
).VALUES(
|
|
snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
|
snap.Actor.Type, actorUserID, actorUsername,
|
|
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.ActorUserID.SET(es.EXCLUDED.ActorUserID),
|
|
es.ActorUsername.SET(es.EXCLUDED.ActorUsername),
|
|
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: actorFromColumns(row.ActorType, row.ActorUserID, row.ActorUsername),
|
|
ReasonCode: row.ReasonCode,
|
|
StartsAt: row.StartsAt,
|
|
MaxRegisteredRaceNames: row.MaxRegisteredRaceNames,
|
|
UpdatedAt: row.UpdatedAt,
|
|
}
|
|
if row.EndsAt != nil {
|
|
t := *row.EndsAt
|
|
out.EndsAt = &t
|
|
}
|
|
return out
|
|
}
|
|
|
|
// actorToColumnArgs converts an ActorRef into the (actor_user_id,
|
|
// actor_username) values for jet INSERT VALUES. A nil-typed `any` lands
|
|
// as SQL NULL through the database/sql driver. Type=="user" parses ID
|
|
// as a UUID; Type=="admin" stores ID verbatim as the username;
|
|
// everything else (system, unknown) writes both columns as NULL. An
|
|
// empty ID is allowed for "user" so synthetic system events that label
|
|
// themselves as "user" do not fail.
|
|
func actorToColumnArgs(actor ActorRef) (any, any, error) {
|
|
switch strings.TrimSpace(actor.Type) {
|
|
case "user":
|
|
id := strings.TrimSpace(actor.ID)
|
|
if id == "" {
|
|
return nil, nil, nil
|
|
}
|
|
uid, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("user store: actor id %q is not a uuid: %w", actor.ID, err)
|
|
}
|
|
return uid, nil, nil
|
|
case "admin":
|
|
if strings.TrimSpace(actor.ID) == "" {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, actor.ID, nil
|
|
default:
|
|
return nil, nil, nil
|
|
}
|
|
}
|
|
|
|
// actorToColumnExprs is the typed-expression analogue of
|
|
// actorToColumnArgs for the UPDATE SET sites. jet's generated bindings
|
|
// type uuid columns as ColumnString (the dialect emits an explicit
|
|
// CAST), so both returned expressions are StringExpression.
|
|
func actorToColumnExprs(actor ActorRef) (postgres.StringExpression, postgres.StringExpression, error) {
|
|
uidArg, nameArg, err := actorToColumnArgs(actor)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
uidExpr := postgres.StringExp(postgres.NULL)
|
|
if uid, ok := uidArg.(uuid.UUID); ok {
|
|
uidExpr = postgres.UUID(uid)
|
|
}
|
|
nameExpr := postgres.StringExp(postgres.NULL)
|
|
if name, ok := nameArg.(string); ok {
|
|
nameExpr = postgres.String(name)
|
|
}
|
|
return uidExpr, nameExpr, nil
|
|
}
|
|
|
|
// actorFromColumns reconstructs an ActorRef from the (actor_type,
|
|
// actor_user_id, actor_username) triple read from an audit row. The
|
|
// non-nil column wins; both nil yields an empty ID.
|
|
func actorFromColumns(actorType string, userID *uuid.UUID, username *string) ActorRef {
|
|
out := ActorRef{Type: actorType}
|
|
switch {
|
|
case userID != nil:
|
|
out.ID = userID.String()
|
|
case username != nil:
|
|
out.ID = *username
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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
|
|
}
|