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