R1: schema & naming reset — squash migrations, rename variants
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod data; verified schema-identical to the chain via a pg_dump diff + the green integration suite) and rename the game-variant labels english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the backend, the FlatBuffers wire values and the UI. dawg filenames and the Go enum identifiers are unchanged; the i18n display keys are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
This commit is contained in:
@@ -37,7 +37,7 @@ var gooseMu sync.Mutex
|
||||
// ApplyMigrations runs every pending Up migration embedded in the backend
|
||||
// binary against db. The schema is created upfront so goose's bookkeeping table
|
||||
// (`goose_db_version`, scoped to the DSN search_path) has somewhere to land
|
||||
// before the first migration runs; migration 00001_init.sql re-asserts the
|
||||
// before the first migration runs; the baseline migration re-asserts the
|
||||
// schema with IF NOT EXISTS, so the double-create is idempotent.
|
||||
//
|
||||
// The apply is retried on transient connection errors. Both steps are
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
-- +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')),
|
||||
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;
|
||||
@@ -1,60 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Initial schema for the Scrabble backend service: durable accounts, their
|
||||
-- platform/email identities, and opaque server sessions.
|
||||
--
|
||||
-- Every backend table lives in the `backend` schema. The schema is created here
|
||||
-- so a fresh database can apply this migration, and search_path is pinned for
|
||||
-- the rest of the migration so the CREATE statements land in `backend` without
|
||||
-- qualifying every object. Production also pins search_path via
|
||||
-- BACKEND_POSTGRES_DSN.
|
||||
CREATE SCHEMA IF NOT EXISTS backend;
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
-- Durable internal accounts. Guests are session-only and never reach this table.
|
||||
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(),
|
||||
CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru'))
|
||||
);
|
||||
|
||||
-- Platform and email identities attached to an account. external_id is the
|
||||
-- platform user id (kind='telegram') or the email address (kind='email');
|
||||
-- confirmed flips true once an email confirm-code is verified (later stages).
|
||||
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')),
|
||||
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);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE sessions;
|
||||
DROP TABLE identities;
|
||||
DROP TABLE accounts;
|
||||
@@ -1,133 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 3 game domain: the per-game lifecycle, the dictionary-independent move
|
||||
-- journal, word-check complaints and per-account statistics, plus two account
|
||||
-- columns the game domain needs.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
-- Extend accounts with the per-user away window (one interval per day, in the
|
||||
-- account's local time_zone) honoured by the turn-timeout sweeper -- and, in a
|
||||
-- later stage, by the robot's sleep -- and a hint wallet (purchasable hints; the
|
||||
-- purchase flow lands later, so the balance defaults to empty). Profile editing
|
||||
-- of the away window arrives with the profile surface (Stage 4).
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN away_start time NOT NULL DEFAULT '00:00',
|
||||
ADD COLUMN away_end time NOT NULL DEFAULT '07:00',
|
||||
ADD COLUMN hint_balance integer NOT NULL DEFAULT 0,
|
||||
ADD CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0);
|
||||
|
||||
-- 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,
|
||||
CONSTRAINT games_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
|
||||
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')
|
||||
)
|
||||
);
|
||||
-- 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 (guests and robots are revisited when they arrive). 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: the
|
||||
-- acting rack, and for a play its direction, placed tiles and formed words; for
|
||||
-- an exchange the swapped tiles. 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 and the resolution lifecycle land in Stage 9, which
|
||||
-- owns the status state machine; Stage 3 only ever writes 'open'.
|
||||
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()
|
||||
);
|
||||
CREATE INDEX complaints_status_idx ON complaints (status);
|
||||
|
||||
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
|
||||
-- Guests have no durable account and never appear here. A draw increments draws
|
||||
-- only (neither wins nor losses). max_word_points is the best single move score
|
||||
-- (which already folds 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()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE account_stats;
|
||||
DROP TABLE complaints;
|
||||
DROP TABLE game_moves;
|
||||
DROP TABLE game_players;
|
||||
DROP TABLE games;
|
||||
ALTER TABLE accounts
|
||||
DROP CONSTRAINT accounts_hint_balance_chk,
|
||||
DROP COLUMN hint_balance,
|
||||
DROP COLUMN away_end,
|
||||
DROP COLUMN away_start;
|
||||
@@ -1,136 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 4 lobby & social: the friend graph, per-user blocks, per-game chat (with
|
||||
-- nudge folded in as a message kind), email confirm-codes, and friend-game
|
||||
-- invitations -- plus the per-game drop-out tile disposition the multi-player
|
||||
-- engine needs. Matchmaking is an in-memory pool and persists nothing.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
-- The disposition of a dropped-out player's tiles in a game with three or more
|
||||
-- seats (docs/ARCHITECTURE.md §6), chosen at creation: 'remove' burns them
|
||||
-- (default), 'return' puts them back in the bag. Moot for a two-player game,
|
||||
-- which ends on the first drop-out. engine.DropoutTiles owns the stable labels.
|
||||
ALTER TABLE games
|
||||
ADD COLUMN dropout_tiles text NOT NULL DEFAULT 'remove',
|
||||
ADD CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'));
|
||||
|
||||
-- The friend graph. A row is created by the requester as 'pending' and flipped to
|
||||
-- 'accepted' by the addressee; declining, cancelling or unfriending deletes the
|
||||
-- row. Friendship is symmetric: a player's friends are the accepted rows in
|
||||
-- either direction. A pair has at most one row (guarded in Go against either
|
||||
-- direction existing).
|
||||
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')),
|
||||
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
|
||||
);
|
||||
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
|
||||
|
||||
-- Per-user blocks. blocker_id has blocked blocked_id; the effect is applied
|
||||
-- mutually by the social checks (a block in either direction suppresses chat
|
||||
-- visibility and prevents requests/invitations between the pair).
|
||||
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 on input, where the content filter also rejects
|
||||
-- links/emails/phone numbers). sender_ip holds the gateway-forwarded client IP as
|
||||
-- a validated string (text, not inet, to avoid go-jet literal friction; the
|
||||
-- gateway populates it in Stage 6). Chat is part of the game archive and is never
|
||||
-- purged; it 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, matching the session model);
|
||||
-- expires_at bounds the TTL and attempts caps brute force. A row is consumed
|
||||
-- (consumed_at stamped) on success. A re-request deletes the prior pending row
|
||||
-- for the same (account, lowercased email) and inserts a fresh one.
|
||||
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 ('english', 'russian_scrabble', 'erudit')),
|
||||
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 pending/accepted/declined 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);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE game_invitation_invitees;
|
||||
DROP TABLE game_invitations;
|
||||
DROP TABLE email_confirmations;
|
||||
DROP TABLE chat_messages;
|
||||
DROP TABLE blocks;
|
||||
DROP TABLE friendships;
|
||||
ALTER TABLE games
|
||||
DROP CONSTRAINT games_dropout_tiles_chk,
|
||||
DROP COLUMN dropout_tiles;
|
||||
@@ -1,15 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 5 robot opponent: admit a 'robot' identity kind so the robot pool can be
|
||||
-- provisioned as durable accounts (one identity row per named robot). This widens
|
||||
-- the identities kind CHECK only; no table or column changes, so the generated
|
||||
-- jet code is unaffected.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE identities DROP CONSTRAINT identities_kind_chk;
|
||||
ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot'));
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE identities DROP CONSTRAINT identities_kind_chk;
|
||||
ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email'));
|
||||
@@ -1,14 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 6 gateway edge: mark ephemeral guest accounts. A guest is a durable
|
||||
-- account row -- the sessions and game_players foreign keys both require one --
|
||||
-- that carries no identity and no profile, friends, stats or history; is_guest
|
||||
-- gates that exclusion (statistics recompute skips guest seats). This adds a
|
||||
-- column, so the generated jet code is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts ADD COLUMN is_guest boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts DROP COLUMN is_guest;
|
||||
@@ -1,45 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 8 social UI: two changes to the friend graph.
|
||||
--
|
||||
-- 1. A declined friend request is now remembered permanently (status 'declined')
|
||||
-- instead of deleting the row, so a recipient's explicit "no" blocks the same
|
||||
-- requester from re-sending (anti-spam). An ignored request still lazily
|
||||
-- expires (30 days, computed from created_at in Go) and can then be re-sent; a
|
||||
-- one-time friend code from the same person bypasses a prior decline. This
|
||||
-- widens friendships_status_chk; the Stage 4 "declining deletes the row" rule
|
||||
-- is superseded (cancelling by the requester still deletes).
|
||||
--
|
||||
-- 2. friend_codes backs the code-redeem add-a-friend path: the player who wants to
|
||||
-- be added issues a one-time 6-digit numeric code; whoever enters it becomes
|
||||
-- their friend immediately. Only the hex-encoded SHA-256 of the code is stored
|
||||
-- (the plaintext is never persisted, matching the session and email-code
|
||||
-- models); expires_at bounds the 12h TTL and consumed_at marks single use. At
|
||||
-- most one live code exists per issuer (issuing a new one clears the prior
|
||||
-- unconsumed code, enforced in Go). This adds a table, so the generated jet code
|
||||
-- is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE friendships
|
||||
DROP CONSTRAINT friendships_status_chk,
|
||||
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined'));
|
||||
|
||||
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()
|
||||
);
|
||||
-- Backs "clear the issuer's prior live code" on issue.
|
||||
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
|
||||
-- Backs the redeem lookup by code hash.
|
||||
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
DROP TABLE friend_codes;
|
||||
ALTER TABLE friendships
|
||||
DROP CONSTRAINT friendships_status_chk,
|
||||
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted'));
|
||||
@@ -1,17 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 9 Telegram integration: a per-account toggle that confines notifications
|
||||
-- to the in-app live stream. When notifications_in_app_only is true (the default),
|
||||
-- the platform side-service (Telegram) sends no out-of-app push; turning it off
|
||||
-- opts into out-of-app push, which the gateway delivers only while the account has
|
||||
-- no live in-app stream, so the in-app and platform channels never duplicate. Adds
|
||||
-- a column, so the generated jet code is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN notifications_in_app_only boolean NOT NULL DEFAULT true;
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
DROP COLUMN notifications_in_app_only;
|
||||
@@ -1,30 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 10 admin & dictionary ops: the word-check complaint resolution lifecycle.
|
||||
-- Stage 3 created complaints with a free-form status (only ever 'open'); the admin
|
||||
-- review queue (this stage) resolves them with a disposition that also feeds the
|
||||
-- offline dictionary-rebuild pipeline: an accepted complaint records whether the
|
||||
-- word should be added or removed, and is marked applied once a rebuilt dictionary
|
||||
-- version is hot-reloaded. No operator identity is recorded (the gateway gates the
|
||||
-- console behind Basic-Auth; the backend keeps no admin principal). Adds columns, so
|
||||
-- the generated jet code is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE complaints
|
||||
ADD COLUMN disposition text NOT NULL DEFAULT '',
|
||||
ADD COLUMN resolution_note text NOT NULL DEFAULT '',
|
||||
ADD COLUMN resolved_at timestamptz,
|
||||
ADD COLUMN applied_in_version text NOT NULL DEFAULT '',
|
||||
ADD CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')),
|
||||
ADD CONSTRAINT complaints_disposition_chk
|
||||
CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove'));
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE complaints
|
||||
DROP CONSTRAINT complaints_disposition_chk,
|
||||
DROP CONSTRAINT complaints_status_chk,
|
||||
DROP COLUMN applied_in_version,
|
||||
DROP COLUMN resolved_at,
|
||||
DROP COLUMN resolution_note,
|
||||
DROP COLUMN disposition;
|
||||
@@ -1,24 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 11 account linking & merge: retire a secondary account into a primary one.
|
||||
-- merged_into/merged_at turn the secondary into an audit tombstone (its identities
|
||||
-- are repointed and its non-shared rows transferred to the primary, but the row is
|
||||
-- kept so the no-cascade game_players/chat/complaints foreign keys of any shared
|
||||
-- finished game stay valid). merged_into self-references accounts and is SET NULL on
|
||||
-- delete so a future guest reaper (PLAN.md TODO-3) can still remove a primary.
|
||||
-- paid_account is a forward-looking lifetime one-time-payment marker (no purchase
|
||||
-- flow yet); the merge ORs it so a paid status is never lost. Adds columns, so the
|
||||
-- generated jet code is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN paid_account boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
|
||||
ADD COLUMN merged_at timestamptz;
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
DROP COLUMN merged_at,
|
||||
DROP COLUMN merged_into,
|
||||
DROP COLUMN paid_account;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 15 dual Telegram bots: service_language records the language tag of the bot
|
||||
-- a Telegram user last authenticated through (their last ValidateInitData). It is
|
||||
-- updated on every Telegram login — new and existing accounts — and routes the
|
||||
-- user's out-of-app push back through the right bot. It is distinct from
|
||||
-- preferred_language (the interface language) and from a game's variant language.
|
||||
-- Nullable: an account that has never signed in through a tagged bot (legacy,
|
||||
-- email-only or guest) has no value, and push routing falls back to
|
||||
-- preferred_language. Adds a column, so the generated jet code is regenerated
|
||||
-- (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN service_language text
|
||||
CHECK (service_language IN ('en', 'ru'));
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
DROP COLUMN service_language;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 17: a per-(game, account) draft the server persists across reloads and devices —
|
||||
-- the player's preferred rack tile order (#4) and the tiles they have laid on the board but
|
||||
-- not yet submitted (#5/#6). board_tiles is reset when an opponent's committed move overlaps
|
||||
-- one of its cells (the draft can no longer be placed). Queried with raw SQL, so no
|
||||
-- generated jet code is needed.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
DROP TABLE game_drafts;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Stage 17: per-account hidden games. A row hides game_id from account_id's own "my games"
|
||||
-- lobby 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). Queried with raw SQL, so no generated
|
||||
-- jet code is needed.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
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
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
DROP TABLE game_hidden;
|
||||
Reference in New Issue
Block a user