From 483e94520933a00540d0f539d2dac0e72bd8dab3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 20 Jun 2026 15:11:40 +0200 Subject: [PATCH] refactor(db): squash migrations into a single baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the incremental goose migrations (00001-00014) into one baseline. There is no production data, so the squash carries no data migration. The baseline was generated from the end-state schema and verified schema-identical to the squashed set (pg_dump diff) plus a full integration run; the default house ad-campaign seed is carried over (a schema-only dump omits it). The per-feature narrative that lived in the squashed migrations is preserved in git history and docs/ARCHITECTURE.md. Deploy note: this breaks goose's version continuity, so the test contour DB must be wiped once — DROP SCHEMA backend CASCADE + restart backend — for goose to re-apply the single baseline fresh. No prod data exists. --- .../postgres/migrations/00001_baseline.sql | 1595 +++++++++++++---- .../migrations/00002_aborted_end_reason.sql | 16 - .../migrations/00003_account_suspensions.sql | 45 - .../postgres/migrations/00004_feedback.sql | 55 - .../migrations/00005_feedback_languages.sql | 15 - .../postgres/migrations/00006_ad_banners.sql | 86 - .../migrations/00007_seat_display_name.sql | 16 - .../postgres/migrations/00008_chat_unread.sql | 20 - .../postgres/migrations/00009_best_move.sql | 25 - .../migrations/00010_stats_moves_hints.sql | 16 - .../migrations/00011_robot_blocks.sql | 27 - .../00012_robot_friend_requests.sql | 28 - .../migrations/00013_game_setup_draws.sql | 31 - .../00014_single_bot_variant_preferences.sql | 45 - 14 files changed, 1260 insertions(+), 760 deletions(-) delete mode 100644 backend/internal/postgres/migrations/00002_aborted_end_reason.sql delete mode 100644 backend/internal/postgres/migrations/00003_account_suspensions.sql delete mode 100644 backend/internal/postgres/migrations/00004_feedback.sql delete mode 100644 backend/internal/postgres/migrations/00005_feedback_languages.sql delete mode 100644 backend/internal/postgres/migrations/00006_ad_banners.sql delete mode 100644 backend/internal/postgres/migrations/00007_seat_display_name.sql delete mode 100644 backend/internal/postgres/migrations/00008_chat_unread.sql delete mode 100644 backend/internal/postgres/migrations/00009_best_move.sql delete mode 100644 backend/internal/postgres/migrations/00010_stats_moves_hints.sql delete mode 100644 backend/internal/postgres/migrations/00011_robot_blocks.sql delete mode 100644 backend/internal/postgres/migrations/00012_robot_friend_requests.sql delete mode 100644 backend/internal/postgres/migrations/00013_game_setup_draws.sql delete mode 100644 backend/internal/postgres/migrations/00014_single_bot_variant_preferences.sql diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index 066dab2..5d3e83b 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -1,358 +1,1283 @@ -- +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; +-- Consolidated baseline schema for the Scrabble backend. It squashes the former +-- incremental migrations (00001-00014) into one starting point; there is no +-- production data, so the squash carries no data migration. The per-feature +-- narrative that lived in the squashed migrations is preserved in git history and +-- in docs/ARCHITECTURE.md. Every object lives in the `backend` schema. SET search_path = backend, pg_catalog; +-- Name: backend; Type: SCHEMA; Schema: -; Owner: - +-- --- 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) +CREATE SCHEMA IF NOT EXISTS backend; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: account_best_move; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.account_best_move ( + account_id uuid NOT NULL, + variant text NOT NULL, + score integer NOT NULL, + tiles jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL ); --- 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); +-- +-- Name: account_roles; Type: TABLE; Schema: backend; Owner: - +-- --- 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, - -- open_deadline_at is set only while status='open' (an auto-match game awaiting an - -- opponent): the instant the matchmaking reaper substitutes a robot if no human has - -- joined by then. NULL for every active and finished game. - open_deadline_at timestamptz, - dropout_tiles text NOT NULL DEFAULT 'remove', - multiple_words_per_turn boolean NOT NULL DEFAULT true, - -- vs_ai marks an "honest AI" game: the player knowingly plays a robot that joins - -- and moves at once (no disguise). It drives the 🤖 display, the disabled chat/nudge, - -- the skipped statistics and the robot's immediate, sleepless, nudge-free behaviour. - -- The robot-filled auto-match game (the "random player" path) keeps vs_ai=false, so - -- the disguised-robot opponent is never revealed. turn_timeout_secs holds the 7-day - -- inactivity clock for these games (the per-move timeout is reused as the abandon rule). - vs_ai boolean NOT NULL DEFAULT false, - CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')), - CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished', 'open')), - 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'; --- The matchmaking reaper scans open games due for a robot substitution; a partial index --- keeps it off the active and finished games. -CREATE INDEX games_open_idx ON games (open_deadline_at) WHERE status = 'open'; - --- Seats in turn order (seat 0 moves first), one row per player. account_id is a durable --- account, or NULL for the still-empty opponent seat of an auto-match game waiting for an --- opponent (status='open'); it is filled when a human or a robot joins. score is the --- running/final score, is_winner is stamped on finish (false for every seat on a draw), --- hints_used counts the seat's total hints used this game (the free per-game allowance plus --- the wallet-charged ones; the first HintsPerPlayer are the allowance). -CREATE TABLE game_players ( - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - seat smallint NOT NULL, - account_id uuid 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')) +CREATE TABLE backend.account_roles ( + account_id uuid NOT NULL, + role text NOT NULL, + granted_at timestamp with time zone DEFAULT now() NOT NULL ); --- 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 installed. -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); --- The active dictionary version new games pin, kept as the single source of truth --- so it survives a restart (the in-memory engine.Registry is rebuilt from the --- dictionary directory on every boot). A singleton row: id is always true. The --- admin dictionary-update flow sets active_version after it writes and loads a new --- version; new games read it, while in-flight games keep the version stamped on --- their games.dict_version (docs/ARCHITECTURE.md §5). -CREATE TABLE dictionary_state ( - id boolean PRIMARY KEY DEFAULT true, - active_version text NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), +-- +-- Name: account_stats; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.account_stats ( + account_id uuid NOT NULL, + wins integer DEFAULT 0 NOT NULL, + losses integer DEFAULT 0 NOT NULL, + draws integer DEFAULT 0 NOT NULL, + max_game_points integer DEFAULT 0 NOT NULL, + max_word_points integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + moves integer DEFAULT 0 NOT NULL, + hints_used integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: account_suspensions; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.account_suspensions ( + suspension_id uuid NOT NULL, + account_id uuid NOT NULL, + blocked_at timestamp with time zone DEFAULT now() NOT NULL, + blocked_until timestamp with time zone, + reason_en text, + reason_ru text, + reason_id uuid, + lifted_at timestamp with time zone +); + + +-- +-- Name: accounts; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.accounts ( + account_id uuid NOT NULL, + display_name text DEFAULT ''::text NOT NULL, + preferred_language text DEFAULT 'en'::text NOT NULL, + time_zone text DEFAULT 'UTC'::text NOT NULL, + block_chat boolean DEFAULT false NOT NULL, + block_friend_requests boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + away_start time without time zone DEFAULT '00:00:00'::time without time zone NOT NULL, + away_end time without time zone DEFAULT '07:00:00'::time without time zone NOT NULL, + hint_balance integer DEFAULT 0 NOT NULL, + is_guest boolean DEFAULT false NOT NULL, + notifications_in_app_only boolean DEFAULT true NOT NULL, + paid_account boolean DEFAULT false NOT NULL, + merged_into uuid, + merged_at timestamp with time zone, + flagged_high_rate_at timestamp with time zone, + variant_preferences text[] DEFAULT ARRAY['erudit_ru'::text] NOT NULL, + CONSTRAINT accounts_hint_balance_chk CHECK ((hint_balance >= 0)), + CONSTRAINT accounts_preferred_language_chk CHECK ((preferred_language = ANY (ARRAY['en'::text, 'ru'::text]))), + CONSTRAINT accounts_variant_preferences_nonempty_chk CHECK ((cardinality(variant_preferences) >= 1)), + CONSTRAINT accounts_variant_preferences_subset_chk CHECK ((variant_preferences <@ ARRAY['scrabble_en'::text, 'scrabble_ru'::text, 'erudit_ru'::text])) +); + + +-- +-- Name: ad_campaigns; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.ad_campaigns ( + campaign_id uuid NOT NULL, + name text NOT NULL, + weight integer NOT NULL, + is_default boolean DEFAULT false NOT NULL, + enabled boolean DEFAULT true NOT NULL, + starts_at timestamp with time zone, + ends_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT ad_campaigns_default_window_chk CHECK (((NOT is_default) OR ((starts_at IS NULL) AND (ends_at IS NULL)))), + CONSTRAINT ad_campaigns_weight_chk CHECK (((weight >= 1) AND (weight <= 100))), + CONSTRAINT ad_campaigns_window_chk CHECK (((starts_at IS NULL) OR (ends_at IS NULL) OR (starts_at <= ends_at))) +); + + +-- +-- Name: ad_messages; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.ad_messages ( + message_id uuid NOT NULL, + campaign_id uuid NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + body_en text NOT NULL, + body_ru text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: ad_settings; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.ad_settings ( + id boolean DEFAULT true NOT NULL, + hold_ms integer NOT NULL, + edge_pause_ms integer NOT NULL, + scroll_px_per_sec integer NOT NULL, + fade_out_ms integer NOT NULL, + gap_ms integer NOT NULL, + fade_in_ms integer NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT ad_settings_singleton_chk CHECK (id) +); + + +-- +-- Name: blocks; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.blocks ( + blocker_id uuid NOT NULL, + blocked_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT blocks_distinct_chk CHECK ((blocker_id <> blocked_id)) +); + + +-- +-- Name: chat_messages; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.chat_messages ( + message_id uuid NOT NULL, + game_id uuid NOT NULL, + sender_id uuid NOT NULL, + kind text DEFAULT 'message'::text NOT NULL, + body text DEFAULT ''::text NOT NULL, + sender_ip text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + unread_seats smallint DEFAULT 0 NOT NULL, + CONSTRAINT chat_messages_body_len_chk CHECK ((char_length(body) <= 60)), + CONSTRAINT chat_messages_kind_chk CHECK ((kind = ANY (ARRAY['message'::text, 'nudge'::text]))), + CONSTRAINT chat_messages_nudge_empty_chk CHECK (((kind <> 'nudge'::text) OR (body = ''::text))) +); + + +-- +-- Name: complaints; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.complaints ( + complaint_id uuid NOT NULL, + complainant_id uuid NOT NULL, + game_id uuid NOT NULL, + variant text NOT NULL, + dict_version text NOT NULL, + word text NOT NULL, + was_valid boolean NOT NULL, + note text DEFAULT ''::text NOT NULL, + status text DEFAULT 'open'::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + disposition text DEFAULT ''::text NOT NULL, + resolution_note text DEFAULT ''::text NOT NULL, + resolved_at timestamp with time zone, + applied_in_version text DEFAULT ''::text NOT NULL, + CONSTRAINT complaints_disposition_chk CHECK ((disposition = ANY (ARRAY[''::text, 'reject'::text, 'accept_add'::text, 'accept_remove'::text]))), + CONSTRAINT complaints_status_chk CHECK ((status = ANY (ARRAY['open'::text, 'resolved'::text]))) +); + + +-- +-- Name: dictionary_state; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.dictionary_state ( + id boolean DEFAULT true NOT NULL, + active_version text NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT dictionary_state_singleton_chk CHECK (id) ); --- 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() + +-- +-- Name: email_confirmations; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.email_confirmations ( + confirmation_id uuid NOT NULL, + account_id uuid NOT NULL, + email text NOT NULL, + code_hash text NOT NULL, + expires_at timestamp with time zone NOT NULL, + attempts smallint DEFAULT 0 NOT NULL, + consumed_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT email_confirmations_attempts_chk CHECK ((attempts >= 0)) ); --- 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); +-- +-- Name: feedback_messages; Type: TABLE; Schema: backend; Owner: - +-- --- 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', - multiple_words_per_turn boolean NOT NULL DEFAULT true, - 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) +CREATE TABLE backend.feedback_messages ( + message_id uuid NOT NULL, + account_id uuid NOT NULL, + body text NOT NULL, + attachment bytea, + attachment_name text, + sender_ip text, + channel text NOT NULL, + read_at timestamp with time zone, + archived_at timestamp with time zone, + reply_body text, + replied_at timestamp with time zone, + reply_read_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + lang text ); --- 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) + +-- +-- Name: friend_codes; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.friend_codes ( + code_id uuid NOT NULL, + account_id uuid NOT NULL, + code_hash text NOT NULL, + expires_at timestamp with time zone NOT NULL, + consumed_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL ); + +-- +-- Name: friendships; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.friendships ( + requester_id uuid NOT NULL, + addressee_id uuid NOT NULL, + status text DEFAULT 'pending'::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + responded_at timestamp with time zone, + CONSTRAINT friendships_distinct_chk CHECK ((requester_id <> addressee_id)), + CONSTRAINT friendships_status_chk CHECK ((status = ANY (ARRAY['pending'::text, 'accepted'::text, 'declined'::text]))) +); + + +-- +-- Name: game_drafts; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.game_drafts ( + game_id uuid NOT NULL, + account_id uuid NOT NULL, + rack_order text DEFAULT ''::text NOT NULL, + board_tiles jsonb DEFAULT '[]'::jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: game_hidden; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.game_hidden ( + account_id uuid NOT NULL, + game_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: game_invitation_invitees; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.game_invitation_invitees ( + invitation_id uuid NOT NULL, + account_id uuid NOT NULL, + seat smallint NOT NULL, + response text DEFAULT 'pending'::text NOT NULL, + responded_at timestamp with time zone, + CONSTRAINT game_invitation_invitees_response_chk CHECK ((response = ANY (ARRAY['pending'::text, 'accepted'::text, 'declined'::text]))), + CONSTRAINT game_invitation_invitees_seat_chk CHECK (((seat >= 1) AND (seat <= 3))) +); + + +-- +-- Name: game_invitations; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.game_invitations ( + invitation_id uuid NOT NULL, + inviter_id uuid NOT NULL, + variant text NOT NULL, + turn_timeout_secs integer NOT NULL, + hints_allowed boolean DEFAULT true NOT NULL, + hints_per_player smallint DEFAULT 1 NOT NULL, + dropout_tiles text DEFAULT 'remove'::text NOT NULL, + multiple_words_per_turn boolean DEFAULT true NOT NULL, + status text DEFAULT 'pending'::text NOT NULL, + game_id uuid, + expires_at timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT game_invitations_dropout_tiles_chk CHECK ((dropout_tiles = ANY (ARRAY['remove'::text, 'return'::text]))), + CONSTRAINT game_invitations_hints_per_player_chk CHECK ((hints_per_player >= 0)), + CONSTRAINT game_invitations_status_chk CHECK ((status = ANY (ARRAY['pending'::text, 'declined'::text, 'cancelled'::text, 'expired'::text, 'started'::text]))), + CONSTRAINT game_invitations_turn_timeout_chk CHECK ((turn_timeout_secs > 0)), + CONSTRAINT game_invitations_variant_chk CHECK ((variant = ANY (ARRAY['scrabble_en'::text, 'scrabble_ru'::text, 'erudit_ru'::text]))) +); + + +-- +-- Name: game_moves; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.game_moves ( + game_id uuid NOT NULL, + seq integer NOT NULL, + seat smallint NOT NULL, + action text NOT NULL, + score integer DEFAULT 0 NOT NULL, + running_total integer DEFAULT 0 NOT NULL, + exchanged_count smallint DEFAULT 0 NOT NULL, + payload text DEFAULT '{}'::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT game_moves_action_chk CHECK ((action = ANY (ARRAY['play'::text, 'pass'::text, 'exchange'::text, 'resign'::text, 'timeout'::text]))) +); + + +-- +-- Name: game_players; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.game_players ( + game_id uuid NOT NULL, + seat smallint NOT NULL, + account_id uuid, + score integer DEFAULT 0 NOT NULL, + hints_used smallint DEFAULT 0 NOT NULL, + is_winner boolean DEFAULT false NOT NULL, + display_name text DEFAULT ''::text NOT NULL +); + + +-- +-- Name: game_setup_draws; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.game_setup_draws ( + game_id uuid NOT NULL, + round smallint NOT NULL, + pick_no smallint NOT NULL, + account_id uuid, + letter text NOT NULL, + is_blank boolean DEFAULT false NOT NULL, + draw_rank smallint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: games; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.games ( + game_id uuid NOT NULL, + variant text NOT NULL, + dict_version text NOT NULL, + seed bigint NOT NULL, + status text DEFAULT 'active'::text NOT NULL, + players smallint NOT NULL, + to_move smallint DEFAULT 0 NOT NULL, + turn_started_at timestamp with time zone DEFAULT now() NOT NULL, + turn_timeout_secs integer NOT NULL, + hints_allowed boolean DEFAULT true NOT NULL, + hints_per_player smallint DEFAULT 1 NOT NULL, + move_count integer DEFAULT 0 NOT NULL, + end_reason text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + finished_at timestamp with time zone, + open_deadline_at timestamp with time zone, + dropout_tiles text DEFAULT 'remove'::text NOT NULL, + multiple_words_per_turn boolean DEFAULT true NOT NULL, + vs_ai boolean DEFAULT false NOT NULL, + CONSTRAINT games_dropout_tiles_chk CHECK ((dropout_tiles = ANY (ARRAY['remove'::text, 'return'::text]))), + CONSTRAINT games_end_reason_chk CHECK (((end_reason IS NULL) OR (end_reason = ANY (ARRAY['out_of_tiles'::text, 'scoreless'::text, 'resign'::text, 'timeout'::text, 'aborted'::text])))), + CONSTRAINT games_hints_per_player_chk CHECK ((hints_per_player >= 0)), + CONSTRAINT games_players_chk CHECK (((players >= 2) AND (players <= 4))), + CONSTRAINT games_status_chk CHECK ((status = ANY (ARRAY['active'::text, 'finished'::text, 'open'::text]))), + 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_variant_chk CHECK ((variant = ANY (ARRAY['scrabble_en'::text, 'scrabble_ru'::text, 'erudit_ru'::text]))) +); + + +-- +-- Name: identities; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.identities ( + identity_id uuid NOT NULL, + account_id uuid NOT NULL, + kind text NOT NULL, + external_id text NOT NULL, + confirmed boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'email'::text, 'robot'::text]))) +); + + +-- +-- Name: robot_blocks; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.robot_blocks ( + id uuid NOT NULL, + blocker_id uuid NOT NULL, + game_id uuid NOT NULL, + seat smallint NOT NULL, + robot_id uuid NOT NULL, + display_name text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: robot_friend_requests; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.robot_friend_requests ( + id uuid NOT NULL, + requester_id uuid NOT NULL, + game_id uuid NOT NULL, + seat smallint NOT NULL, + robot_id uuid NOT NULL, + display_name text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: sessions; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.sessions ( + session_id uuid NOT NULL, + account_id uuid NOT NULL, + token_hash text NOT NULL, + status text DEFAULT 'active'::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + last_seen_at timestamp with time zone, + revoked_at timestamp with time zone, + CONSTRAINT sessions_status_chk CHECK ((status = ANY (ARRAY['active'::text, 'revoked'::text]))) +); + + +-- +-- Name: suspension_reasons; Type: TABLE; Schema: backend; Owner: - +-- + +CREATE TABLE backend.suspension_reasons ( + reason_id uuid NOT NULL, + text_en text NOT NULL, + text_ru text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: account_best_move account_best_move_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_best_move + ADD CONSTRAINT account_best_move_pkey PRIMARY KEY (account_id, variant); + + +-- +-- Name: account_roles account_roles_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_roles + ADD CONSTRAINT account_roles_pkey PRIMARY KEY (account_id, role); + + +-- +-- Name: account_stats account_stats_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_stats + ADD CONSTRAINT account_stats_pkey PRIMARY KEY (account_id); + + +-- +-- Name: account_suspensions account_suspensions_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_suspensions + ADD CONSTRAINT account_suspensions_pkey PRIMARY KEY (suspension_id); + + +-- +-- Name: accounts accounts_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.accounts + ADD CONSTRAINT accounts_pkey PRIMARY KEY (account_id); + + +-- +-- Name: ad_campaigns ad_campaigns_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.ad_campaigns + ADD CONSTRAINT ad_campaigns_pkey PRIMARY KEY (campaign_id); + + +-- +-- Name: ad_messages ad_messages_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.ad_messages + ADD CONSTRAINT ad_messages_pkey PRIMARY KEY (message_id); + + +-- +-- Name: ad_settings ad_settings_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.ad_settings + ADD CONSTRAINT ad_settings_pkey PRIMARY KEY (id); + + +-- +-- Name: blocks blocks_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.blocks + ADD CONSTRAINT blocks_pkey PRIMARY KEY (blocker_id, blocked_id); + + +-- +-- Name: chat_messages chat_messages_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.chat_messages + ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (message_id); + + +-- +-- Name: complaints complaints_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.complaints + ADD CONSTRAINT complaints_pkey PRIMARY KEY (complaint_id); + + +-- +-- Name: dictionary_state dictionary_state_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.dictionary_state + ADD CONSTRAINT dictionary_state_pkey PRIMARY KEY (id); + + +-- +-- Name: email_confirmations email_confirmations_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.email_confirmations + ADD CONSTRAINT email_confirmations_pkey PRIMARY KEY (confirmation_id); + + +-- +-- Name: feedback_messages feedback_messages_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.feedback_messages + ADD CONSTRAINT feedback_messages_pkey PRIMARY KEY (message_id); + + +-- +-- Name: friend_codes friend_codes_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.friend_codes + ADD CONSTRAINT friend_codes_pkey PRIMARY KEY (code_id); + + +-- +-- Name: friendships friendships_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.friendships + ADD CONSTRAINT friendships_pkey PRIMARY KEY (requester_id, addressee_id); + + +-- +-- Name: game_drafts game_drafts_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_drafts + ADD CONSTRAINT game_drafts_pkey PRIMARY KEY (game_id, account_id); + + +-- +-- Name: game_hidden game_hidden_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_hidden + ADD CONSTRAINT game_hidden_pkey PRIMARY KEY (account_id, game_id); + + +-- +-- Name: game_invitation_invitees game_invitation_invitees_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_invitation_invitees + ADD CONSTRAINT game_invitation_invitees_pkey PRIMARY KEY (invitation_id, account_id); + + +-- +-- Name: game_invitations game_invitations_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_invitations + ADD CONSTRAINT game_invitations_pkey PRIMARY KEY (invitation_id); + + +-- +-- Name: game_moves game_moves_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_moves + ADD CONSTRAINT game_moves_pkey PRIMARY KEY (game_id, seq); + + +-- +-- Name: game_players game_players_account_key; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_players + ADD CONSTRAINT game_players_account_key UNIQUE (game_id, account_id); + + +-- +-- Name: game_players game_players_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_players + ADD CONSTRAINT game_players_pkey PRIMARY KEY (game_id, seat); + + +-- +-- Name: game_setup_draws game_setup_draws_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_setup_draws + ADD CONSTRAINT game_setup_draws_pkey PRIMARY KEY (game_id, round, pick_no); + + +-- +-- Name: games games_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.games + ADD CONSTRAINT games_pkey PRIMARY KEY (game_id); + + +-- +-- Name: identities identities_kind_external_id_key; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.identities + ADD CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id); + + +-- +-- Name: identities identities_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.identities + ADD CONSTRAINT identities_pkey PRIMARY KEY (identity_id); + + +-- +-- Name: robot_blocks robot_blocks_blocker_id_game_id_seat_key; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_blocks + ADD CONSTRAINT robot_blocks_blocker_id_game_id_seat_key UNIQUE (blocker_id, game_id, seat); + + +-- +-- Name: robot_blocks robot_blocks_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_blocks + ADD CONSTRAINT robot_blocks_pkey PRIMARY KEY (id); + + +-- +-- Name: robot_friend_requests robot_friend_requests_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_friend_requests + ADD CONSTRAINT robot_friend_requests_pkey PRIMARY KEY (id); + + +-- +-- Name: robot_friend_requests robot_friend_requests_requester_id_game_id_seat_key; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_friend_requests + ADD CONSTRAINT robot_friend_requests_requester_id_game_id_seat_key UNIQUE (requester_id, game_id, seat); + + +-- +-- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.sessions + ADD CONSTRAINT sessions_pkey PRIMARY KEY (session_id); + + +-- +-- Name: sessions sessions_token_hash_key; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.sessions + ADD CONSTRAINT sessions_token_hash_key UNIQUE (token_hash); + + +-- +-- Name: suspension_reasons suspension_reasons_pkey; Type: CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.suspension_reasons + ADD CONSTRAINT suspension_reasons_pkey PRIMARY KEY (reason_id); + + +-- +-- Name: account_suspensions_account_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX account_suspensions_account_idx ON backend.account_suspensions USING btree (account_id, blocked_at DESC); + + +-- +-- Name: ad_campaigns_default_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE UNIQUE INDEX ad_campaigns_default_idx ON backend.ad_campaigns USING btree (is_default) WHERE is_default; + + +-- +-- Name: ad_messages_campaign_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX ad_messages_campaign_idx ON backend.ad_messages USING btree (campaign_id, "position"); + + +-- +-- Name: blocks_blocked_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX blocks_blocked_idx ON backend.blocks USING btree (blocked_id); + + +-- +-- Name: chat_messages_game_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX chat_messages_game_idx ON backend.chat_messages USING btree (game_id, created_at); + + +-- +-- Name: chat_messages_nudge_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX chat_messages_nudge_idx ON backend.chat_messages USING btree (game_id, sender_id, created_at) WHERE (kind = 'nudge'::text); + + +-- +-- Name: chat_messages_unread_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX chat_messages_unread_idx ON backend.chat_messages USING btree (game_id) WHERE (unread_seats <> 0); + + +-- +-- Name: complaints_status_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX complaints_status_idx ON backend.complaints USING btree (status); + + +-- +-- Name: email_confirmations_account_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX email_confirmations_account_idx ON backend.email_confirmations USING btree (account_id); + + +-- +-- Name: feedback_messages_account_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX feedback_messages_account_idx ON backend.feedback_messages USING btree (account_id, created_at DESC); + + +-- +-- Name: feedback_messages_created_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX feedback_messages_created_idx ON backend.feedback_messages USING btree (created_at DESC); + + +-- +-- Name: friend_codes_account_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX friend_codes_account_idx ON backend.friend_codes USING btree (account_id); + + +-- +-- Name: friend_codes_code_hash_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX friend_codes_code_hash_idx ON backend.friend_codes USING btree (code_hash); + + +-- +-- Name: friendships_addressee_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX friendships_addressee_idx ON backend.friendships USING btree (addressee_id); + + +-- +-- Name: game_invitation_invitees_account_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX game_invitation_invitees_account_idx ON backend.game_invitation_invitees USING btree (account_id); + + +-- +-- Name: game_invitations_inviter_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX game_invitations_inviter_idx ON backend.game_invitations USING btree (inviter_id); + + +-- +-- Name: game_players_account_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX game_players_account_idx ON backend.game_players USING btree (account_id); + + +-- +-- Name: games_active_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX games_active_idx ON backend.games USING btree (turn_started_at) WHERE (status = 'active'::text); + + +-- +-- Name: games_open_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX games_open_idx ON backend.games USING btree (open_deadline_at) WHERE (status = 'open'::text); + + +-- +-- Name: games_open_match_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX games_open_match_idx ON backend.games USING btree (variant, multiple_words_per_turn, created_at) WHERE (status = 'open'::text); + + +-- +-- Name: identities_account_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX identities_account_idx ON backend.identities USING btree (account_id); + + +-- +-- Name: robot_blocks_blocker_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX robot_blocks_blocker_idx ON backend.robot_blocks USING btree (blocker_id); + + +-- +-- Name: robot_friend_requests_requester_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX robot_friend_requests_requester_idx ON backend.robot_friend_requests USING btree (requester_id); + + +-- +-- Name: sessions_account_idx; Type: INDEX; Schema: backend; Owner: - +-- + +CREATE INDEX sessions_account_idx ON backend.sessions USING btree (account_id); + + +-- +-- Name: account_best_move account_best_move_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_best_move + ADD CONSTRAINT account_best_move_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: account_roles account_roles_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_roles + ADD CONSTRAINT account_roles_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: account_stats account_stats_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_stats + ADD CONSTRAINT account_stats_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: account_suspensions account_suspensions_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_suspensions + ADD CONSTRAINT account_suspensions_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: account_suspensions account_suspensions_reason_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.account_suspensions + ADD CONSTRAINT account_suspensions_reason_id_fkey FOREIGN KEY (reason_id) REFERENCES backend.suspension_reasons(reason_id) ON DELETE SET NULL; + + +-- +-- Name: accounts accounts_merged_into_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.accounts + ADD CONSTRAINT accounts_merged_into_fkey FOREIGN KEY (merged_into) REFERENCES backend.accounts(account_id) ON DELETE SET NULL; + + +-- +-- Name: ad_messages ad_messages_campaign_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.ad_messages + ADD CONSTRAINT ad_messages_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES backend.ad_campaigns(campaign_id) ON DELETE CASCADE; + + +-- +-- Name: blocks blocks_blocked_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.blocks + ADD CONSTRAINT blocks_blocked_id_fkey FOREIGN KEY (blocked_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: blocks blocks_blocker_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.blocks + ADD CONSTRAINT blocks_blocker_id_fkey FOREIGN KEY (blocker_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: chat_messages chat_messages_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.chat_messages + ADD CONSTRAINT chat_messages_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: chat_messages chat_messages_sender_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.chat_messages + ADD CONSTRAINT chat_messages_sender_id_fkey FOREIGN KEY (sender_id) REFERENCES backend.accounts(account_id); + + +-- +-- Name: complaints complaints_complainant_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.complaints + ADD CONSTRAINT complaints_complainant_id_fkey FOREIGN KEY (complainant_id) REFERENCES backend.accounts(account_id); + + +-- +-- Name: complaints complaints_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.complaints + ADD CONSTRAINT complaints_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: email_confirmations email_confirmations_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.email_confirmations + ADD CONSTRAINT email_confirmations_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: feedback_messages feedback_messages_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.feedback_messages + ADD CONSTRAINT feedback_messages_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: friend_codes friend_codes_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.friend_codes + ADD CONSTRAINT friend_codes_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: friendships friendships_addressee_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.friendships + ADD CONSTRAINT friendships_addressee_id_fkey FOREIGN KEY (addressee_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: friendships friendships_requester_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.friendships + ADD CONSTRAINT friendships_requester_id_fkey FOREIGN KEY (requester_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: game_drafts game_drafts_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_drafts + ADD CONSTRAINT game_drafts_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: game_drafts game_drafts_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_drafts + ADD CONSTRAINT game_drafts_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: game_hidden game_hidden_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_hidden + ADD CONSTRAINT game_hidden_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: game_hidden game_hidden_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_hidden + ADD CONSTRAINT game_hidden_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: game_invitation_invitees game_invitation_invitees_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_invitation_invitees + ADD CONSTRAINT game_invitation_invitees_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: game_invitation_invitees game_invitation_invitees_invitation_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_invitation_invitees + ADD CONSTRAINT game_invitation_invitees_invitation_id_fkey FOREIGN KEY (invitation_id) REFERENCES backend.game_invitations(invitation_id) ON DELETE CASCADE; + + +-- +-- Name: game_invitations game_invitations_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_invitations + ADD CONSTRAINT game_invitations_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE SET NULL; + + +-- +-- Name: game_invitations game_invitations_inviter_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_invitations + ADD CONSTRAINT game_invitations_inviter_id_fkey FOREIGN KEY (inviter_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: game_moves game_moves_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_moves + ADD CONSTRAINT game_moves_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: game_players game_players_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_players + ADD CONSTRAINT game_players_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id); + + +-- +-- Name: game_players game_players_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_players + ADD CONSTRAINT game_players_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: game_setup_draws game_setup_draws_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_setup_draws + ADD CONSTRAINT game_setup_draws_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: game_setup_draws game_setup_draws_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.game_setup_draws + ADD CONSTRAINT game_setup_draws_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: identities identities_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.identities + ADD CONSTRAINT identities_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: robot_blocks robot_blocks_blocker_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_blocks + ADD CONSTRAINT robot_blocks_blocker_id_fkey FOREIGN KEY (blocker_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: robot_blocks robot_blocks_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_blocks + ADD CONSTRAINT robot_blocks_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: robot_blocks robot_blocks_robot_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_blocks + ADD CONSTRAINT robot_blocks_robot_id_fkey FOREIGN KEY (robot_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: robot_friend_requests robot_friend_requests_game_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_friend_requests + ADD CONSTRAINT robot_friend_requests_game_id_fkey FOREIGN KEY (game_id) REFERENCES backend.games(game_id) ON DELETE CASCADE; + + +-- +-- Name: robot_friend_requests robot_friend_requests_requester_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_friend_requests + ADD CONSTRAINT robot_friend_requests_requester_id_fkey FOREIGN KEY (requester_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: robot_friend_requests robot_friend_requests_robot_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.robot_friend_requests + ADD CONSTRAINT robot_friend_requests_robot_id_fkey FOREIGN KEY (robot_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- +-- Name: sessions sessions_account_id_fkey; Type: FK CONSTRAINT; Schema: backend; Owner: - +-- + +ALTER TABLE ONLY backend.sessions + ADD CONSTRAINT sessions_account_id_fkey FOREIGN KEY (account_id) REFERENCES backend.accounts(account_id) ON DELETE CASCADE; + + +-- Seed data carried over from the squashed migrations (a schema-only dump omits it): +-- the default house ad campaign, its message and the banner display timings. +INSERT INTO ad_campaigns (campaign_id, name, weight, is_default, enabled) +VALUES ('00000000-0000-0000-0000-0000000000ad', 'Default (house)', 100, true, true); +INSERT INTO ad_messages (message_id, campaign_id, position, body_en, body_ru) +VALUES ( + '00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0, + 'Tip: a play using all 7 tiles earns a +50 bonus.', + 'Совет: ход всеми 7 фишками приносит бонус +50 очков.' +); +INSERT INTO ad_settings (id, hold_ms, edge_pause_ms, scroll_px_per_sec, fade_out_ms, gap_ms, fade_in_ms) +VALUES (true, 60000, 5000, 40, 1000, 250, 1000); + + -- +goose Down DROP SCHEMA IF EXISTS backend CASCADE; diff --git a/backend/internal/postgres/migrations/00002_aborted_end_reason.sql b/backend/internal/postgres/migrations/00002_aborted_end_reason.sql deleted file mode 100644 index 90b37be..0000000 --- a/backend/internal/postgres/migrations/00002_aborted_end_reason.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up --- Allow end_reason = 'aborted': a game whose journal can no longer be reconstructed (a --- recorded move became illegal under tightened rules) is closed as a draw rather than left --- unopenable. See engine.EndAborted and Service.voidGame. -SET search_path = backend, pg_catalog; -ALTER TABLE games DROP CONSTRAINT games_end_reason_chk; -ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK ( - end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout', 'aborted') -); - --- +goose Down -SET search_path = backend, pg_catalog; -ALTER TABLE games DROP CONSTRAINT games_end_reason_chk; -ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK ( - end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout') -); diff --git a/backend/internal/postgres/migrations/00003_account_suspensions.sql b/backend/internal/postgres/migrations/00003_account_suspensions.sql deleted file mode 100644 index 26e6639..0000000 --- a/backend/internal/postgres/migrations/00003_account_suspensions.sql +++ /dev/null @@ -1,45 +0,0 @@ --- +goose Up --- Manual account blocking ("suspension"), the operator's hard counterpart to the soft, --- reversible accounts.flagged_high_rate_at marker. Named "suspension" throughout the schema --- and Go to stay distinct from the peer-to-peer `blocks` table (one player muting another); --- the wire/UI vocabulary the player sees is "blocked". A suspension forces the player to --- forfeit every active game and replaces their whole UI with a terminal "blocked" screen until --- it is lifted or (for a temporary one) expires. See docs/ARCHITECTURE.md §12. -SET search_path = backend, pg_catalog; - --- The operator-editable reason picklist, one row per reason with its English and Russian text. --- A suspension snapshots the chosen text (account_suspensions.reason_en/ru), so editing or --- deleting a reason here never changes or breaks a reason already shown to a blocked player. -CREATE TABLE suspension_reasons ( - reason_id uuid PRIMARY KEY, - text_en text NOT NULL, - text_ru text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - --- The block history: one row per block. blocked_until is NULL for a permanent block and the --- expiry instant for a temporary one; lifted_at is stamped when an operator unblocks early. --- reason_en/reason_ru are the text snapshot taken at block time (NULL when no reason was --- cited); reason_id keeps a loose link to the picklist entry for later analytics and is nulled --- if that entry is deleted (the snapshot remains the source of truth shown to the player). An --- account is currently blocked when its newest row has lifted_at IS NULL AND (blocked_until IS --- NULL OR blocked_until > now()). -CREATE TABLE account_suspensions ( - suspension_id uuid PRIMARY KEY, - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - blocked_at timestamptz NOT NULL DEFAULT now(), - blocked_until timestamptz, - reason_en text, - reason_ru text, - reason_id uuid REFERENCES suspension_reasons (reason_id) ON DELETE SET NULL, - lifted_at timestamptz -); --- The enforcement gate looks up the newest suspension for an account on every authenticated --- request; this index serves that "latest by account" probe. -CREATE INDEX account_suspensions_account_idx ON account_suspensions (account_id, blocked_at DESC); - --- +goose Down -SET search_path = backend, pg_catalog; -DROP TABLE IF EXISTS account_suspensions; -DROP TABLE IF EXISTS suspension_reasons; diff --git a/backend/internal/postgres/migrations/00004_feedback.sql b/backend/internal/postgres/migrations/00004_feedback.sql deleted file mode 100644 index c4fd632..0000000 --- a/backend/internal/postgres/migrations/00004_feedback.sql +++ /dev/null @@ -1,55 +0,0 @@ --- +goose Up --- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the --- operators from Settings -> Info, each with an optional single attachment, plus the operator's --- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer) --- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first --- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md. -SET search_path = backend, pg_catalog; - --- One feedback message. read_at marks "the operator has dealt with this" (set by every console --- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids --- a new submission while the player has any read_at IS NULL message. archived_at files a handled --- message away. The reply lives on the same row: reply_body/replied_at are stamped when the --- operator answers; reply_read_at is stamped when the player's app first fetches the reply for --- display ("delivered = read"), and the reply is hidden from the player one week after that. --- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original --- file name (its extension drives the safe content-type at serve time). sender_ip is the --- gateway-forwarded client IP (validated, like chat); channel is the submitting platform --- (telegram/ios/android/web, validated in Go). The sender's interface/bot language snapshots --- (lang, channel_lang) are added additively in 00005. -CREATE TABLE feedback_messages ( - message_id uuid PRIMARY KEY, - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - body text NOT NULL, - attachment bytea, - attachment_name text, - sender_ip text, - channel text NOT NULL, - read_at timestamptz, - archived_at timestamptz, - reply_body text, - replied_at timestamptz, - reply_read_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now() -); --- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the --- console's user-scoped search all look an account up newest-first. -CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC); --- The console queue lists messages newest-first across all accounts. -CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC); - --- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role, --- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a --- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted --- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users. -CREATE TABLE account_roles ( - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - role text NOT NULL, - granted_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (account_id, role) -); - --- +goose Down -SET search_path = backend, pg_catalog; -DROP TABLE IF EXISTS account_roles; -DROP TABLE IF EXISTS feedback_messages; diff --git a/backend/internal/postgres/migrations/00005_feedback_languages.sql b/backend/internal/postgres/migrations/00005_feedback_languages.sql deleted file mode 100644 index ea3a92c..0000000 --- a/backend/internal/postgres/migrations/00005_feedback_languages.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- Snapshot the sender's languages on each feedback message, taken at submit time so the --- operator console shows the state as it was, not the account's current settings (the same --- snapshot discipline as a suspension's reason). lang is the sender's interface language; --- channel_lang is the connector bot language (en/ru) when the message arrived through an --- external connector (Telegram), else NULL. Additive over 00004 so it applies forward-safe. -SET search_path = backend, pg_catalog; - -ALTER TABLE feedback_messages ADD COLUMN lang text; -ALTER TABLE feedback_messages ADD COLUMN channel_lang text; - --- +goose Down -SET search_path = backend, pg_catalog; -ALTER TABLE feedback_messages DROP COLUMN IF EXISTS channel_lang; -ALTER TABLE feedback_messages DROP COLUMN IF EXISTS lang; diff --git a/backend/internal/postgres/migrations/00006_ad_banners.sql b/backend/internal/postgres/migrations/00006_ad_banners.sql deleted file mode 100644 index 3b2ecac..0000000 --- a/backend/internal/postgres/migrations/00006_ad_banners.sql +++ /dev/null @@ -1,86 +0,0 @@ --- +goose Up --- Server-driven advertising banner ("advertising network"): operator-managed campaigns rotated in --- the client's one-line announcement strip. A campaign is one placement order with a show weight --- (percent); simultaneously active campaigns compete for shows in proportion to their weights. The --- single perpetual default campaign fills the unsold remainder up to 100%. Each campaign carries --- one or more bilingual messages (en + ru, both mandatory), shown by the viewer's bot (service) --- language. Display timings are global (one settings row). Eligibility (who sees a banner at all) --- reuses account fields + the no_banner role, so it needs no schema here. See docs/ARCHITECTURE.md --- and internal/ads. -SET search_path = backend, pg_catalog; - --- One advertising campaign = one placement order. weight is the show percentage (1..100). is_default --- marks the single perpetual house campaign that fills the remainder up to 100% and is never deleted; --- it has no validity window (its stored weight is nominal — the rotation derives its effective weight --- as max(0, 100 - sum of active timed weights)). A time-limited campaign is active while now() lies in --- [starts_at, ends_at] (a null bound is open-ended on that side). enabled toggles a campaign without --- deleting it. -CREATE TABLE ad_campaigns ( - campaign_id uuid PRIMARY KEY, - name text NOT NULL, - weight integer NOT NULL, - is_default boolean NOT NULL DEFAULT false, - enabled boolean NOT NULL DEFAULT true, - starts_at timestamptz, - ends_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - CONSTRAINT ad_campaigns_weight_chk CHECK (weight BETWEEN 1 AND 100), - -- The default campaign is perpetual (no window). - CONSTRAINT ad_campaigns_default_window_chk CHECK (NOT is_default OR (starts_at IS NULL AND ends_at IS NULL)), - -- A closed window must not be inverted. - CONSTRAINT ad_campaigns_window_chk CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at <= ends_at) -); --- At most one default campaign. -CREATE UNIQUE INDEX ad_campaigns_default_idx ON ad_campaigns (is_default) WHERE is_default; - --- One bilingual message of a campaign. Both languages are mandatory: the client shows the variant for --- the viewer's bot (service) language. position orders the messages within a campaign (the round-robin --- order when the campaign wins a display slot). body_* is minimal markdown (text + links). -CREATE TABLE ad_messages ( - message_id uuid PRIMARY KEY, - campaign_id uuid NOT NULL REFERENCES ad_campaigns (campaign_id) ON DELETE CASCADE, - position integer NOT NULL DEFAULT 0, - body_en text NOT NULL, - body_ru text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); --- Messages of a campaign are read in display order. -CREATE INDEX ad_messages_campaign_idx ON ad_messages (campaign_id, position); - --- Global banner display timings, a single row (id is always TRUE). The client rotator reads these: --- hold_ms is how long one message shows; edge_pause_ms and scroll_px_per_sec drive the scroll of a --- message wider than the strip; the transition between messages is fade_out_ms -> gap_ms -> fade_in_ms. --- All are clamped in Go on update. -CREATE TABLE ad_settings ( - id boolean PRIMARY KEY DEFAULT true, - hold_ms integer NOT NULL, - edge_pause_ms integer NOT NULL, - scroll_px_per_sec integer NOT NULL, - fade_out_ms integer NOT NULL, - gap_ms integer NOT NULL, - fade_in_ms integer NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - CONSTRAINT ad_settings_singleton_chk CHECK (id) -); - --- Seed the perpetual default campaign with one bilingual house message, and the default timings --- (the previous hardcoded UI defaults plus the new fade sequence). Fixed UUIDs keep the seed --- deterministic across environments. -INSERT INTO ad_campaigns (campaign_id, name, weight, is_default, enabled) -VALUES ('00000000-0000-0000-0000-0000000000ad', 'Default (house)', 100, true, true); -INSERT INTO ad_messages (message_id, campaign_id, position, body_en, body_ru) -VALUES ( - '00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0, - 'Tip: a play using all 7 tiles earns a +50 bonus.', - 'Совет: ход всеми 7 фишками приносит бонус +50 очков.' -); -INSERT INTO ad_settings (id, hold_ms, edge_pause_ms, scroll_px_per_sec, fade_out_ms, gap_ms, fade_in_ms) -VALUES (true, 60000, 5000, 40, 1000, 250, 1000); - --- +goose Down -SET search_path = backend, pg_catalog; -DROP TABLE IF EXISTS ad_settings; -DROP TABLE IF EXISTS ad_messages; -DROP TABLE IF EXISTS ad_campaigns; diff --git a/backend/internal/postgres/migrations/00007_seat_display_name.sql b/backend/internal/postgres/migrations/00007_seat_display_name.sql deleted file mode 100644 index b06f23c..0000000 --- a/backend/internal/postgres/migrations/00007_seat_display_name.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up --- A per-seat snapshot of the player's display name, captured when the seat is taken: --- the human's then-current display name, or a disguised robot's freshly composed --- per-game name. Storing the name on the seat (rather than always reading the account) --- freezes what the opponent sees for the life of the game — a later rename no longer --- rewrites past games — and lets the small robot pool present an ever-changing crowd of --- differently named opponents. An empty value means "no snapshot": the reader falls back --- to the account's current display name (legacy rows / pre-migration games). See --- docs/ARCHITECTURE.md §7. -SET search_path = backend, pg_catalog; - -ALTER TABLE game_players ADD COLUMN display_name text NOT NULL DEFAULT ''; - --- +goose Down -SET search_path = backend, pg_catalog; -ALTER TABLE game_players DROP COLUMN display_name; diff --git a/backend/internal/postgres/migrations/00008_chat_unread.sql b/backend/internal/postgres/migrations/00008_chat_unread.sql deleted file mode 100644 index 613dea6..0000000 --- a/backend/internal/postgres/migrations/00008_chat_unread.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +goose Up --- A per-message bitmask of the seats that have NOT yet read this chat entry: bit i is --- set while seat i still has the message unread, and is cleared when that seat reads it --- (opens the move history / chat, or — for a nudge — takes its move). A text message --- starts with the bits of every seated recipient except the sender; a nudge starts with --- only the awaited player's bit. The mask is inverted so "anything unread" is a plain --- `unread_seats <> 0`, which the lobby badge, the admin filter and the unread gauge all --- use. The read time itself is not retained. See docs/ARCHITECTURE.md §8 (Read receipts). -SET search_path = backend, pg_catalog; - -ALTER TABLE chat_messages ADD COLUMN unread_seats smallint NOT NULL DEFAULT 0; - --- Partial index serving the unread scans (per-viewer lobby lookup, admin unread filter, --- the unread-count gauge): only the comparatively few still-unread rows are indexed. -CREATE INDEX chat_messages_unread_idx ON chat_messages (game_id) WHERE unread_seats <> 0; - --- +goose Down -SET search_path = backend, pg_catalog; -DROP INDEX chat_messages_unread_idx; -ALTER TABLE chat_messages DROP COLUMN unread_seats; diff --git a/backend/internal/postgres/migrations/00009_best_move.sql b/backend/internal/postgres/migrations/00009_best_move.sql deleted file mode 100644 index 529a863..0000000 --- a/backend/internal/postgres/migrations/00009_best_move.sql +++ /dev/null @@ -1,25 +0,0 @@ --- +goose Up --- Per-account, per-variant best single move: the highest-scoring play the account has --- ever made in each game variant. It exists so the statistics screen can show the word --- itself broken down by variant, not just the dimensionless aggregate --- account_stats.max_word_points. tiles is the move's main word as an ordered JSON array of --- {letter, value, blank} objects (value 0 and blank true for a wildcard), so the client --- renders it as game tiles without needing the variant's alphabet table. score is the --- play's total points (every word it formed plus the all-tiles bonus), matching --- max_word_points. A row is replaced only by a strictly higher-scoring play. It is written --- at game finish alongside account_stats; guest and honest-AI games never record statistics, --- so they never write here. See docs/ARCHITECTURE.md §9. -SET search_path = backend, pg_catalog; - -CREATE TABLE account_best_move ( - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - variant text NOT NULL, - score integer NOT NULL, - tiles jsonb NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (account_id, variant) -); - --- +goose Down -SET search_path = backend, pg_catalog; -DROP TABLE IF EXISTS account_best_move; diff --git a/backend/internal/postgres/migrations/00010_stats_moves_hints.sql b/backend/internal/postgres/migrations/00010_stats_moves_hints.sql deleted file mode 100644 index 5295235..0000000 --- a/backend/internal/postgres/migrations/00010_stats_moves_hints.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up --- account_stats gains two lifetime counters for the player's statistics screen: moves — the --- player's plays (tile placements; passes and exchanges do not count) — and hints_used — every --- hint the player took (the free per-game allowance plus the wallet-charged ones). Both are --- summed at game finish over the same games that feed the rest of account_stats (durable --- non-guest accounts; honest-AI games are skipped), so the screen can show the "hint share" --- = hints_used / moves. See docs/ARCHITECTURE.md §9. -SET search_path = backend, pg_catalog; - -ALTER TABLE account_stats ADD COLUMN moves integer NOT NULL DEFAULT 0; -ALTER TABLE account_stats ADD COLUMN hints_used integer NOT NULL DEFAULT 0; - --- +goose Down -SET search_path = backend, pg_catalog; -ALTER TABLE account_stats DROP COLUMN hints_used; -ALTER TABLE account_stats DROP COLUMN moves; diff --git a/backend/internal/postgres/migrations/00011_robot_blocks.sql b/backend/internal/postgres/migrations/00011_robot_blocks.sql deleted file mode 100644 index 32a9083..0000000 --- a/backend/internal/postgres/migrations/00011_robot_blocks.sql +++ /dev/null @@ -1,27 +0,0 @@ --- +goose Up --- Per-game blocks of a disguised-robot opponent. A disguised robot is a shared pool --- account reused across games under different per-game names, so it must never go into --- `blocks` (that would make the same robot look blocked in the blocker's other games under --- other names, leaking that it is a bot, and show its pool name instead of the one seen). --- Each such block is recorded here against the specific game + seat, snapshotting the name --- the player saw, so the blocked list shows it as a distinct personality and the in-game --- card re-marks that one seat. The real robot account is never blocked, so the matchmaker --- leaves robots free. Unblocking deletes the row. -SET search_path = backend, pg_catalog; - -CREATE TABLE robot_blocks ( - id uuid PRIMARY KEY, - blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - seat smallint NOT NULL, - robot_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - display_name text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (blocker_id, game_id, seat) -); - -CREATE INDEX robot_blocks_blocker_idx ON robot_blocks (blocker_id); - --- +goose Down -SET search_path = backend, pg_catalog; -DROP TABLE robot_blocks; diff --git a/backend/internal/postgres/migrations/00012_robot_friend_requests.sql b/backend/internal/postgres/migrations/00012_robot_friend_requests.sql deleted file mode 100644 index 4ec1c39..0000000 --- a/backend/internal/postgres/migrations/00012_robot_friend_requests.sql +++ /dev/null @@ -1,28 +0,0 @@ --- +goose Up --- Per-game friend requests sent to a disguised-robot opponent. A disguised robot is a --- shared pool account reused across games under different per-game names, so such a --- request must never go into `friendships` (that would befriend/await the shared robot --- account, leak that it is a bot across the requester's other games under other names, --- and pin the in-game "request sent" state to the shared account instead of this seat). --- Each request is recorded here against the specific game + seat, snapshotting the name --- the player saw, so the in-game scoreboard can re-mark that one seat as already --- requested. The robot ignores it (it never becomes a friendship); a background reaper --- deletes a row once its game has been finished for more than the retention window. -SET search_path = backend, pg_catalog; - -CREATE TABLE robot_friend_requests ( - id uuid PRIMARY KEY, - requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - seat smallint NOT NULL, - robot_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - display_name text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (requester_id, game_id, seat) -); - -CREATE INDEX robot_friend_requests_requester_idx ON robot_friend_requests (requester_id); - --- +goose Down -SET search_path = backend, pg_catalog; -DROP TABLE robot_friend_requests; diff --git a/backend/internal/postgres/migrations/00013_game_setup_draws.sql b/backend/internal/postgres/migrations/00013_game_setup_draws.sql deleted file mode 100644 index 5e66004..0000000 --- a/backend/internal/postgres/migrations/00013_game_setup_draws.sql +++ /dev/null @@ -1,31 +0,0 @@ --- +goose Up --- The first-move draw (docs/ARCHITECTURE.md §6): before a game starts, each seated --- player draws one tile from the bag and the tile closest to "A" decides who moves --- first (a blank supersedes all letters); players tied for the best tile re-draw --- until a single leader remains. Each draw uses fresh entropy (not the game's --- deterministic bag seed), so this record — not a seed — is the only account of how --- the order was chosen. It is kept for tournaments, where the draw becomes a manual --- per-tile call. The record is dictionary-independent: the decoded letter, the blank --- flag and the numeric draw rank describe each draw without any alphabet table. The --- winner is reflected as seat 0 in game_players, so no order column is duplicated --- here. In auto-match the opponent is unknown at draw time (the draw runs against a --- synthetic placeholder when the game opens), so their draw rows carry a NULL account_id, --- back-filled when a real opponent joins. Hidden from players for now; surfaced only in the --- admin console. -SET search_path = backend, pg_catalog; - -CREATE TABLE game_setup_draws ( - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - round smallint NOT NULL, - pick_no smallint NOT NULL, - account_id uuid REFERENCES accounts (account_id) ON DELETE CASCADE, - letter text NOT NULL, - is_blank boolean NOT NULL DEFAULT false, - draw_rank smallint NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (game_id, round, pick_no) -); - --- +goose Down -SET search_path = backend, pg_catalog; -DROP TABLE game_setup_draws; diff --git a/backend/internal/postgres/migrations/00014_single_bot_variant_preferences.sql b/backend/internal/postgres/migrations/00014_single_bot_variant_preferences.sql deleted file mode 100644 index 421d49e..0000000 --- a/backend/internal/postgres/migrations/00014_single_bot_variant_preferences.sql +++ /dev/null @@ -1,45 +0,0 @@ --- +goose Up --- Single-bot collapse + per-user variant preferences. --- --- With one Telegram bot there is no longer a "service language" (which bot a player --- signed in through), and variant availability is no longer gated by the bot's --- supported languages but by an explicit per-account preference. variant_preferences --- is the set of game variants (engine.Variant stable labels) a player is willing to be --- matched into: it gates the New Game picker and the matchmaker, while an invited friend --- may still accept any variant. New accounts default to Erudit only (a DB-level default, --- so no application seed is needed); the set may never be empty and must be a subset of --- the three known variants. feedback_messages.channel_lang (the bot a message arrived --- through) likewise loses meaning under one bot and is dropped; the sender's interface --- language (lang) is kept. -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts - ADD COLUMN variant_preferences text[] NOT NULL DEFAULT ARRAY['erudit_ru']::text[], - ADD CONSTRAINT accounts_variant_preferences_nonempty_chk - CHECK (cardinality(variant_preferences) >= 1), - ADD CONSTRAINT accounts_variant_preferences_subset_chk - CHECK (variant_preferences <@ ARRAY['scrabble_en', 'scrabble_ru', 'erudit_ru']::text[]); - -ALTER TABLE accounts DROP COLUMN service_language; - -ALTER TABLE feedback_messages DROP COLUMN channel_lang; - --- The auto-match pairing query filters open games by (variant, multiple_words_per_turn) --- and takes the oldest by created_at; games_open_idx (open_deadline_at) does not serve it. --- A partial index over open games turns the scan-and-sort into a single index seek. -CREATE INDEX games_open_match_idx ON games (variant, multiple_words_per_turn, created_at) - WHERE status = 'open'; - --- +goose Down -SET search_path = backend, pg_catalog; - -DROP INDEX games_open_match_idx; - -ALTER TABLE feedback_messages ADD COLUMN channel_lang text; - -ALTER TABLE accounts ADD COLUMN service_language text CHECK (service_language IN ('en', 'ru')); - -ALTER TABLE accounts - DROP CONSTRAINT accounts_variant_preferences_subset_chk, - DROP CONSTRAINT accounts_variant_preferences_nonempty_chk, - DROP COLUMN variant_preferences;