-- +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;