From 17dbfc6dc0b51df17331a61ce25e619cf7e76a10 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 03:31:01 +0100 Subject: [PATCH] feat(01-02): design initial database schema migration - 23 tables covering venue settings, chip sets, blind structures, payout structures, buy-in configs, tournament templates, tournaments, players, tables, seating, transactions, bubble prizes, audit trail, and operators - All financial columns use INTEGER (int64 cents, never REAL/FLOAT) - Audit trail append-only enforced by SQLite triggers (reject UPDATE except undone_by, reject DELETE) - All tournament-specific tables reference tournament_id for multi-tournament support - Comprehensive indexes on foreign keys and common query patterns - Players table with UUID PK for cross-venue portability Co-Authored-By: Claude Opus 4.6 --- .../store/migrations/001_initial_schema.sql | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 internal/store/migrations/001_initial_schema.sql diff --git a/internal/store/migrations/001_initial_schema.sql b/internal/store/migrations/001_initial_schema.sql new file mode 100644 index 0000000..349c613 --- /dev/null +++ b/internal/store/migrations/001_initial_schema.sql @@ -0,0 +1,439 @@ +-- 001_initial_schema.sql +-- Phase 1: Complete tournament engine schema +-- All financial columns use INTEGER (int64 cents, never REAL/FLOAT) +-- All timestamps use INTEGER (Unix epoch seconds) +-- All UUIDs stored as TEXT + +-- ============================================================================= +-- Venue & Settings +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS venue_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row + venue_name TEXT NOT NULL DEFAULT '', + currency_code TEXT NOT NULL DEFAULT 'DKK', + currency_symbol TEXT NOT NULL DEFAULT 'kr', + rounding_denomination INTEGER NOT NULL DEFAULT 5000, -- 50.00 kr in cents + receipt_mode TEXT NOT NULL DEFAULT 'digital' CHECK (receipt_mode IN ('off', 'digital', 'print', 'both')), + timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- ============================================================================= +-- Building Blocks (venue-level reusable) +-- ============================================================================= + +-- Chip Sets +CREATE TABLE IF NOT EXISTS chip_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS chip_denominations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chip_set_id INTEGER NOT NULL REFERENCES chip_sets(id) ON DELETE CASCADE, + value INTEGER NOT NULL, -- cents + color_hex TEXT NOT NULL DEFAULT '#FFFFFF', + label TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE(chip_set_id, value) +); + +-- Blind Structures +CREATE TABLE IF NOT EXISTS blind_structures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + game_type_default TEXT NOT NULL DEFAULT 'nlhe', + notes TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS blind_levels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + structure_id INTEGER NOT NULL REFERENCES blind_structures(id) ON DELETE CASCADE, + position INTEGER NOT NULL, -- sort order + level_type TEXT NOT NULL DEFAULT 'round' CHECK (level_type IN ('round', 'break')), + game_type TEXT NOT NULL DEFAULT 'nlhe', + small_blind INTEGER NOT NULL DEFAULT 0, + big_blind INTEGER NOT NULL DEFAULT 0, + ante INTEGER NOT NULL DEFAULT 0, + bb_ante INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER NOT NULL DEFAULT 900, -- 15 min default + chip_up_denomination_value INTEGER, -- nullable, cents + notes TEXT NOT NULL DEFAULT '', + UNIQUE(structure_id, position) +); + +-- Payout Structures +CREATE TABLE IF NOT EXISTS payout_structures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS payout_brackets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + structure_id INTEGER NOT NULL REFERENCES payout_structures(id) ON DELETE CASCADE, + min_entries INTEGER NOT NULL, + max_entries INTEGER NOT NULL, + CHECK (min_entries <= max_entries) +); + +CREATE TABLE IF NOT EXISTS payout_tiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bracket_id INTEGER NOT NULL REFERENCES payout_brackets(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + percentage_basis_points INTEGER NOT NULL, -- e.g. 5000 = 50.00% + UNIQUE(bracket_id, position) +); + +-- Buy-in Configs +CREATE TABLE IF NOT EXISTS buyin_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + buyin_amount INTEGER NOT NULL DEFAULT 0, -- cents + starting_chips INTEGER NOT NULL DEFAULT 0, + rake_total INTEGER NOT NULL DEFAULT 0, -- cents + bounty_amount INTEGER NOT NULL DEFAULT 0, -- cents + bounty_chip INTEGER NOT NULL DEFAULT 0, + rebuy_allowed INTEGER NOT NULL DEFAULT 0, + rebuy_cost INTEGER NOT NULL DEFAULT 0, -- cents + rebuy_chips INTEGER NOT NULL DEFAULT 0, + rebuy_rake INTEGER NOT NULL DEFAULT 0, -- cents + rebuy_limit INTEGER NOT NULL DEFAULT 0, -- 0 = unlimited + rebuy_level_cutoff INTEGER, -- nullable + rebuy_time_cutoff_seconds INTEGER, -- nullable + rebuy_chip_threshold INTEGER, -- nullable + addon_allowed INTEGER NOT NULL DEFAULT 0, + addon_cost INTEGER NOT NULL DEFAULT 0, -- cents + addon_chips INTEGER NOT NULL DEFAULT 0, + addon_rake INTEGER NOT NULL DEFAULT 0, -- cents + addon_level_start INTEGER, -- nullable + addon_level_end INTEGER, -- nullable + reentry_allowed INTEGER NOT NULL DEFAULT 0, + reentry_limit INTEGER NOT NULL DEFAULT 0, + late_reg_level_cutoff INTEGER, -- nullable + late_reg_time_cutoff_seconds INTEGER, -- nullable + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS rake_splits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + buyin_config_id INTEGER NOT NULL REFERENCES buyin_configs(id) ON DELETE CASCADE, + category TEXT NOT NULL CHECK (category IN ('house', 'staff', 'league', 'season_reserve')), + amount INTEGER NOT NULL DEFAULT 0, -- cents + UNIQUE(buyin_config_id, category) +); + +-- Points Formulas +CREATE TABLE IF NOT EXISTS points_formulas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + expression TEXT NOT NULL DEFAULT '', + variables TEXT NOT NULL DEFAULT '{}', -- JSON + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- ============================================================================= +-- Tournament Templates +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS tournament_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + chip_set_id INTEGER NOT NULL REFERENCES chip_sets(id), + blind_structure_id INTEGER NOT NULL REFERENCES blind_structures(id), + payout_structure_id INTEGER NOT NULL REFERENCES payout_structures(id), + buyin_config_id INTEGER NOT NULL REFERENCES buyin_configs(id), + points_formula_id INTEGER REFERENCES points_formulas(id), -- nullable + min_players INTEGER NOT NULL DEFAULT 2, + max_players INTEGER, -- nullable + early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0, + early_signup_cutoff TEXT, -- nullable, datetime or player count + punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0, + is_pko INTEGER NOT NULL DEFAULT 0, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- ============================================================================= +-- Tournaments (runtime instances) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS tournaments ( + id TEXT PRIMARY KEY, -- UUID + name TEXT NOT NULL, + template_id INTEGER REFERENCES tournament_templates(id), -- nullable, reference only + + -- Copied config (local changes don't affect template) + chip_set_id INTEGER NOT NULL REFERENCES chip_sets(id), + blind_structure_id INTEGER NOT NULL REFERENCES blind_structures(id), + payout_structure_id INTEGER NOT NULL REFERENCES payout_structures(id), + buyin_config_id INTEGER NOT NULL REFERENCES buyin_configs(id), + points_formula_id INTEGER REFERENCES points_formulas(id), -- nullable + + status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ( + 'created', 'registering', 'running', 'paused', + 'final_table', 'completed', 'cancelled' + )), + + min_players INTEGER NOT NULL DEFAULT 2, + max_players INTEGER, -- nullable + early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0, + early_signup_cutoff TEXT, -- nullable + punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0, + is_pko INTEGER NOT NULL DEFAULT 0, + + -- Runtime state + current_level INTEGER NOT NULL DEFAULT 0, + clock_state TEXT NOT NULL DEFAULT 'stopped' CHECK (clock_state IN ('stopped', 'running', 'paused')), + clock_remaining_ns INTEGER NOT NULL DEFAULT 0, + total_elapsed_ns INTEGER NOT NULL DEFAULT 0, + hand_for_hand INTEGER NOT NULL DEFAULT 0, + + started_at INTEGER, -- nullable + ended_at INTEGER, -- nullable + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- ============================================================================= +-- Players +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, -- UUID + name TEXT NOT NULL, + nickname TEXT, + email TEXT, + phone TEXT, + photo_url TEXT, + notes TEXT, + custom_fields TEXT, -- JSON + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS tournament_players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + player_id TEXT NOT NULL REFERENCES players(id), + status TEXT NOT NULL DEFAULT 'registered' CHECK (status IN ( + 'registered', 'active', 'busted', 'deal' + )), + + seat_table_id INTEGER REFERENCES tables(id), -- nullable + seat_position INTEGER, -- nullable + + buy_in_at INTEGER, -- nullable + bust_out_at INTEGER, -- nullable + bust_out_order INTEGER, -- nullable, position when busted + finishing_position INTEGER, -- nullable, final position + + current_chips INTEGER NOT NULL DEFAULT 0, + rebuys INTEGER NOT NULL DEFAULT 0, + addons INTEGER NOT NULL DEFAULT 0, + reentries INTEGER NOT NULL DEFAULT 0, + bounty_value INTEGER NOT NULL DEFAULT 0, -- cents, for PKO + bounties_collected INTEGER NOT NULL DEFAULT 0, + prize_amount INTEGER NOT NULL DEFAULT 0, -- cents + points_awarded INTEGER NOT NULL DEFAULT 0, + + early_signup_bonus_applied INTEGER NOT NULL DEFAULT 0, + punctuality_bonus_applied INTEGER NOT NULL DEFAULT 0, + hitman_player_id TEXT REFERENCES players(id), -- nullable, who busted them + + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + + UNIQUE(tournament_id, player_id) +); + +-- ============================================================================= +-- Tables & Seating +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS tables ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + name TEXT NOT NULL, + seat_count INTEGER NOT NULL DEFAULT 9 CHECK (seat_count >= 6 AND seat_count <= 10), + dealer_button_position INTEGER, -- nullable + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS table_blueprints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + table_configs TEXT NOT NULL DEFAULT '[]', -- JSON array of {name, seat_count} + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS balance_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ( + 'pending', 'accepted', 'cancelled', 'expired' + )), + from_table_id INTEGER NOT NULL REFERENCES tables(id), + to_table_id INTEGER NOT NULL REFERENCES tables(id), + player_id TEXT REFERENCES players(id), -- nullable + from_seat INTEGER, -- nullable + to_seat INTEGER, -- nullable + reason TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + resolved_at INTEGER -- nullable +); + +-- ============================================================================= +-- Financial Transactions +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, -- UUID + tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + player_id TEXT NOT NULL REFERENCES players(id), + type TEXT NOT NULL CHECK (type IN ( + 'buyin', 'rebuy', 'addon', 'reentry', + 'bounty_collected', 'bounty_paid', + 'payout', 'rake', 'chop', 'bubble_prize' + )), + amount INTEGER NOT NULL DEFAULT 0, -- cents + chips INTEGER NOT NULL DEFAULT 0, -- chips given/removed + operator_id TEXT NOT NULL, -- who performed the action + receipt_data TEXT, -- nullable, JSON + undone INTEGER NOT NULL DEFAULT 0, + undone_by TEXT, -- nullable, FK audit_entries.id + metadata TEXT, -- nullable, JSON (bounty chain, chop details, etc) + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS bubble_prizes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + amount INTEGER NOT NULL DEFAULT 0, -- cents + funded_from TEXT NOT NULL DEFAULT '[]', -- JSON array of {position, reduction_amount} + status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ( + 'proposed', 'confirmed', 'cancelled' + )), + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- ============================================================================= +-- Audit Trail (append-only) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS audit_entries ( + id TEXT PRIMARY KEY, -- UUID + tournament_id TEXT, -- nullable, some are venue-level + timestamp INTEGER NOT NULL DEFAULT (unixepoch()), -- UnixNano in Go, epoch seconds in SQL default + operator_id TEXT NOT NULL, + action TEXT NOT NULL, -- e.g. 'player.bust', 'financial.buyin', 'clock.pause', 'seat.move' + target_type TEXT NOT NULL DEFAULT '', + target_id TEXT NOT NULL DEFAULT '', + previous_state TEXT, -- JSON + new_state TEXT, -- JSON + metadata TEXT, -- nullable, JSON + undone_by TEXT -- nullable, references audit_entries.id + -- NO UPDATE OR DELETE -- enforced by triggers below +); + +-- Tamper protection: REJECT any UPDATE except setting undone_by on a row where undone_by is currently NULL +CREATE TRIGGER IF NOT EXISTS audit_entries_no_update +BEFORE UPDATE ON audit_entries +WHEN OLD.undone_by IS NOT NULL OR NEW.undone_by IS NULL + OR OLD.id != NEW.id OR OLD.timestamp != NEW.timestamp + OR OLD.operator_id != NEW.operator_id OR OLD.action != NEW.action +BEGIN + SELECT RAISE(ABORT, 'audit_entries is append-only: only undone_by may be set once'); +END; + +-- Tamper protection: REJECT any DELETE on audit_entries entirely +CREATE TRIGGER IF NOT EXISTS audit_entries_no_delete +BEFORE DELETE ON audit_entries +BEGIN + SELECT RAISE(ABORT, 'audit_entries is append-only: deletion is prohibited'); +END; + +-- ============================================================================= +-- Operators +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS operators ( + id TEXT PRIMARY KEY, -- UUID + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, -- bcrypt + role TEXT NOT NULL DEFAULT 'floor' CHECK (role IN ('admin', 'floor', 'viewer')), + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- ============================================================================= +-- Indexes +-- ============================================================================= + +-- Tournament players: active player lookups and rankings +CREATE INDEX IF NOT EXISTS idx_tournament_players_tournament_status + ON tournament_players(tournament_id, status); + +CREATE INDEX IF NOT EXISTS idx_tournament_players_tournament_bust_order + ON tournament_players(tournament_id, bust_out_order); + +-- Transactions: financial summaries and player history +CREATE INDEX IF NOT EXISTS idx_transactions_tournament_type + ON transactions(tournament_id, type); + +CREATE INDEX IF NOT EXISTS idx_transactions_tournament_player + ON transactions(tournament_id, player_id); + +-- Audit entries: log browsing and action filtering +CREATE INDEX IF NOT EXISTS idx_audit_entries_tournament_timestamp + ON audit_entries(tournament_id, timestamp); + +CREATE INDEX IF NOT EXISTS idx_audit_entries_action + ON audit_entries(action); + +-- Tables: active table lookups per tournament +CREATE INDEX IF NOT EXISTS idx_tables_tournament_active + ON tables(tournament_id, is_active); + +-- Foreign key indexes on child tables +CREATE INDEX IF NOT EXISTS idx_chip_denominations_chip_set + ON chip_denominations(chip_set_id); + +CREATE INDEX IF NOT EXISTS idx_blind_levels_structure + ON blind_levels(structure_id); + +CREATE INDEX IF NOT EXISTS idx_payout_brackets_structure + ON payout_brackets(structure_id); + +CREATE INDEX IF NOT EXISTS idx_payout_tiers_bracket + ON payout_tiers(bracket_id); + +CREATE INDEX IF NOT EXISTS idx_rake_splits_buyin_config + ON rake_splits(buyin_config_id); + +CREATE INDEX IF NOT EXISTS idx_balance_suggestions_tournament + ON balance_suggestions(tournament_id); + +CREATE INDEX IF NOT EXISTS idx_bubble_prizes_tournament + ON bubble_prizes(tournament_id); + +CREATE INDEX IF NOT EXISTS idx_tournament_players_player + ON tournament_players(player_id);