Files
scrabble-game/backend/internal/postgres/migrations/00003_social.sql
T
Ilia Denisov bfa8797f8c
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 9s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 9s
Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged.

New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer).

Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
2026-06-02 19:29:30 +02:00

137 lines
7.5 KiB
SQL

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