8881214213
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
328 lines
18 KiB
SQL
328 lines
18 KiB
SQL
-- +goose Up
|
|
-- Baseline schema for the Scrabble backend service, consolidating the incremental
|
|
-- migration history into a single starting point (there is no production data yet,
|
|
-- so the squash carries no data migration). Every backend object lives in the
|
|
-- `backend` schema; it is created here so a fresh database can apply this migration,
|
|
-- and search_path is pinned for the rest of the file so unqualified CREATE
|
|
-- statements land in `backend`. Production also pins search_path via
|
|
-- BACKEND_POSTGRES_DSN.
|
|
CREATE SCHEMA IF NOT EXISTS backend;
|
|
SET search_path = backend, pg_catalog;
|
|
|
|
-- Durable internal accounts. A guest is a durable row with is_guest set and no
|
|
-- identity, excluded from profile/friends/stats/history. The away window (one
|
|
-- interval per day, in the account's time_zone) is honoured by the turn-timeout
|
|
-- sweeper and the robot's sleep; hint_balance is the purchasable-hint wallet.
|
|
-- service_language records the language tag of the bot a Telegram user last
|
|
-- authenticated through (out-of-app push routing), distinct from preferred_language
|
|
-- (the interface language). merged_into/merged_at turn a merged-away secondary into
|
|
-- an audit tombstone; paid_account is a forward-looking one-time-payment marker.
|
|
CREATE TABLE accounts (
|
|
account_id uuid PRIMARY KEY,
|
|
display_name text NOT NULL DEFAULT '',
|
|
preferred_language text NOT NULL DEFAULT 'en',
|
|
time_zone text NOT NULL DEFAULT 'UTC',
|
|
block_chat boolean NOT NULL DEFAULT false,
|
|
block_friend_requests boolean NOT NULL DEFAULT false,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
away_start time NOT NULL DEFAULT '00:00',
|
|
away_end time NOT NULL DEFAULT '07:00',
|
|
hint_balance integer NOT NULL DEFAULT 0,
|
|
is_guest boolean NOT NULL DEFAULT false,
|
|
notifications_in_app_only boolean NOT NULL DEFAULT true,
|
|
paid_account boolean NOT NULL DEFAULT false,
|
|
merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
|
|
merged_at timestamptz,
|
|
service_language text CHECK (service_language IN ('en', 'ru')),
|
|
-- Soft, reversible "suspected high-rate" marker: set once when the gateway
|
|
-- reports sustained rate-limiter rejections past the threshold; an operator
|
|
-- clears it in the admin console. Never an automatic ban.
|
|
flagged_high_rate_at timestamptz,
|
|
CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru')),
|
|
CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0)
|
|
);
|
|
|
|
-- Platform and email identities attached to an account. external_id is the platform
|
|
-- user id (kind='telegram'), the email address (kind='email') or the robot name
|
|
-- (kind='robot'); confirmed flips true once an email confirm-code is verified.
|
|
CREATE TABLE identities (
|
|
identity_id uuid PRIMARY KEY,
|
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
kind text NOT NULL,
|
|
external_id text NOT NULL,
|
|
confirmed boolean NOT NULL DEFAULT false,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot')),
|
|
CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id)
|
|
);
|
|
CREATE INDEX identities_account_idx ON identities (account_id);
|
|
|
|
-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer token;
|
|
-- the plaintext token is never stored. Sessions are revoke-only (no TTL): status
|
|
-- moves active -> revoked and revoked_at is stamped.
|
|
CREATE TABLE sessions (
|
|
session_id uuid PRIMARY KEY,
|
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
token_hash text NOT NULL,
|
|
status text NOT NULL DEFAULT 'active',
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
last_seen_at timestamptz,
|
|
revoked_at timestamptz,
|
|
CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')),
|
|
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash)
|
|
);
|
|
CREATE INDEX sessions_account_idx ON sessions (account_id);
|
|
|
|
-- One match. The live position is event-sourced: this row carries the pinned
|
|
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs, while
|
|
-- game_moves is the append-only journal the in-memory engine.Game is replayed from
|
|
-- (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move clock; its allowed
|
|
-- values are enforced in Go. variant uses engine.Variant's stable labels.
|
|
CREATE TABLE games (
|
|
game_id uuid PRIMARY KEY,
|
|
variant text NOT NULL,
|
|
dict_version text NOT NULL,
|
|
seed bigint NOT NULL,
|
|
status text NOT NULL DEFAULT 'active',
|
|
players smallint NOT NULL,
|
|
to_move smallint NOT NULL DEFAULT 0,
|
|
turn_started_at timestamptz NOT NULL DEFAULT now(),
|
|
turn_timeout_secs integer NOT NULL,
|
|
hints_allowed boolean NOT NULL DEFAULT true,
|
|
hints_per_player smallint NOT NULL DEFAULT 1,
|
|
move_count integer NOT NULL DEFAULT 0,
|
|
end_reason text,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
finished_at timestamptz,
|
|
dropout_tiles text NOT NULL DEFAULT 'remove',
|
|
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
|
|
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
|
|
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
|
|
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
|
|
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
|
CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
|
|
CONSTRAINT games_end_reason_chk CHECK (
|
|
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
|
|
),
|
|
CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'))
|
|
);
|
|
-- The sweeper scans active games oldest-turn-first; a partial index keeps it off the
|
|
-- finished archive.
|
|
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
|
|
|
|
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
|
|
-- durable account. score is the running/final score, is_winner is stamped on finish
|
|
-- (false for every seat on a draw), hints_used counts the per-game allowance consumed
|
|
-- before the profile wallet.
|
|
CREATE TABLE game_players (
|
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
seat smallint NOT NULL,
|
|
account_id uuid NOT NULL REFERENCES accounts (account_id),
|
|
score integer NOT NULL DEFAULT 0,
|
|
hints_used smallint NOT NULL DEFAULT 0,
|
|
is_winner boolean NOT NULL DEFAULT false,
|
|
PRIMARY KEY (game_id, seat),
|
|
CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
|
|
);
|
|
CREATE INDEX game_players_account_idx ON game_players (account_id);
|
|
|
|
-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md §9.1).
|
|
-- seq orders the moves from 0. payload holds the decoded values needed to both replay
|
|
-- the game through the engine and emit GCG without a dictionary. score / running_total
|
|
-- / exchanged_count are lifted out for cheap history rendering.
|
|
CREATE TABLE game_moves (
|
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
seq integer NOT NULL,
|
|
seat smallint NOT NULL,
|
|
action text NOT NULL,
|
|
score integer NOT NULL DEFAULT 0,
|
|
running_total integer NOT NULL DEFAULT 0,
|
|
exchanged_count smallint NOT NULL DEFAULT 0,
|
|
payload text NOT NULL DEFAULT '{}',
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (game_id, seq),
|
|
CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
|
|
);
|
|
|
|
-- Word-check complaints captured in the context of a game's pinned dictionary. The
|
|
-- admin review queue resolves them with a disposition that also feeds the offline
|
|
-- dictionary-rebuild pipeline: an accepted complaint records whether the word is to be
|
|
-- added or removed, and is marked applied once a rebuilt version is hot-reloaded.
|
|
CREATE TABLE complaints (
|
|
complaint_id uuid PRIMARY KEY,
|
|
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
|
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
variant text NOT NULL,
|
|
dict_version text NOT NULL,
|
|
word text NOT NULL,
|
|
was_valid boolean NOT NULL,
|
|
note text NOT NULL DEFAULT '',
|
|
status text NOT NULL DEFAULT 'open',
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
disposition text NOT NULL DEFAULT '',
|
|
resolution_note text NOT NULL DEFAULT '',
|
|
resolved_at timestamptz,
|
|
applied_in_version text NOT NULL DEFAULT '',
|
|
CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')),
|
|
CONSTRAINT complaints_disposition_chk
|
|
CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove'))
|
|
);
|
|
CREATE INDEX complaints_status_idx ON complaints (status);
|
|
|
|
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
|
|
-- Guests have no durable stats. A draw increments draws only. max_word_points is the
|
|
-- best single move score (folding in every word the move formed and the all-tiles bonus).
|
|
CREATE TABLE account_stats (
|
|
account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
wins integer NOT NULL DEFAULT 0,
|
|
losses integer NOT NULL DEFAULT 0,
|
|
draws integer NOT NULL DEFAULT 0,
|
|
max_game_points integer NOT NULL DEFAULT 0,
|
|
max_word_points integer NOT NULL DEFAULT 0,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- The friend graph. A row is created by the requester as 'pending' and flipped to
|
|
-- 'accepted' by the addressee; an explicit 'declined' is remembered (anti-spam),
|
|
-- while cancelling or unfriending deletes the row. Friendship is symmetric.
|
|
CREATE TABLE friendships (
|
|
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
status text NOT NULL DEFAULT 'pending',
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
responded_at timestamptz,
|
|
PRIMARY KEY (requester_id, addressee_id),
|
|
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined')),
|
|
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
|
|
);
|
|
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
|
|
|
|
-- Per-user blocks. The effect is applied mutually by the social checks (a block in
|
|
-- either direction suppresses chat visibility and prevents requests/invitations).
|
|
CREATE TABLE blocks (
|
|
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (blocker_id, blocked_id),
|
|
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
|
|
);
|
|
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
|
|
|
|
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty body,
|
|
-- so one journal carries both chatter and nudges. body is capped at 60 runes (enforced
|
|
-- again in Go, where the content filter also rejects links/emails/phone numbers).
|
|
-- sender_ip holds the gateway-forwarded client IP as a validated string. Chat is part
|
|
-- of the game archive and cascades away only with its game.
|
|
CREATE TABLE chat_messages (
|
|
message_id uuid PRIMARY KEY,
|
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
sender_id uuid NOT NULL REFERENCES accounts (account_id),
|
|
kind text NOT NULL DEFAULT 'message',
|
|
body text NOT NULL DEFAULT '',
|
|
sender_ip text,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
|
|
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
|
|
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
|
|
);
|
|
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
|
|
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
|
|
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
|
|
WHERE kind = 'nudge';
|
|
|
|
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the 6-digit code
|
|
-- (the plaintext is never stored); expires_at bounds the TTL and attempts caps brute
|
|
-- force. A row is consumed (consumed_at stamped) on success.
|
|
CREATE TABLE email_confirmations (
|
|
confirmation_id uuid PRIMARY KEY,
|
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
email text NOT NULL,
|
|
code_hash text NOT NULL,
|
|
expires_at timestamptz NOT NULL,
|
|
attempts smallint NOT NULL DEFAULT 0,
|
|
consumed_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
|
|
);
|
|
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
|
|
|
|
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to 1..3
|
|
-- invitees; the game starts only when every invitee has accepted, and any decline
|
|
-- cancels the whole invitation. Lazily expired after expires_at (no background sweep).
|
|
-- game_id is set when the game is started.
|
|
CREATE TABLE game_invitations (
|
|
invitation_id uuid PRIMARY KEY,
|
|
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
variant text NOT NULL,
|
|
turn_timeout_secs integer NOT NULL,
|
|
hints_allowed boolean NOT NULL DEFAULT true,
|
|
hints_per_player smallint NOT NULL DEFAULT 1,
|
|
dropout_tiles text NOT NULL DEFAULT 'remove',
|
|
status text NOT NULL DEFAULT 'pending',
|
|
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
|
|
expires_at timestamptz NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
|
|
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
|
|
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
|
|
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
|
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
|
|
);
|
|
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
|
|
|
|
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's seat in
|
|
-- the started game (1..3, in invitation order). response tracks each invitee's decision.
|
|
CREATE TABLE game_invitation_invitees (
|
|
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
|
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
seat smallint NOT NULL,
|
|
response text NOT NULL DEFAULT 'pending',
|
|
responded_at timestamptz,
|
|
PRIMARY KEY (invitation_id, account_id),
|
|
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
|
|
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
|
|
);
|
|
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
|
|
|
|
-- One-time friend codes. The player who wants to be added issues a 6-digit code;
|
|
-- whoever enters it becomes their friend. Only the SHA-256 hash is stored; expires_at
|
|
-- bounds the 12h TTL and consumed_at marks single use. At most one live code per issuer.
|
|
CREATE TABLE friend_codes (
|
|
code_id uuid PRIMARY KEY,
|
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
code_hash text NOT NULL,
|
|
expires_at timestamptz NOT NULL,
|
|
consumed_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
|
|
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);
|
|
|
|
-- Per-(game, account) draft the server persists across reloads and devices: the
|
|
-- player's preferred rack tile order and the tiles laid on the board but not yet
|
|
-- submitted. board_tiles is reset when an opponent's committed move overlaps a cell.
|
|
CREATE TABLE game_drafts (
|
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
rack_order text NOT NULL DEFAULT '',
|
|
board_tiles jsonb NOT NULL DEFAULT '[]',
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (game_id, account_id)
|
|
);
|
|
|
|
-- Per-account hidden games. A row hides game_id from account_id's own "my games" list,
|
|
-- leaving it visible to the other players. Only finished games are hidden, and the
|
|
-- action is irreversible by design (there is no un-hide).
|
|
CREATE TABLE game_hidden (
|
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (account_id, game_id)
|
|
);
|
|
|
|
-- +goose Down
|
|
DROP SCHEMA IF EXISTS backend CASCADE;
|