Commit e46b1a95 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add auto-migration on startup with idempotent schema

- Migrator creates _migrations table, tracks executed files by name
- Skips already-run migrations on subsequent deploys
- Schema SQL uses IF NOT EXISTS, DO/EXCEPTION blocks, DROP POLICY IF EXISTS
- Runs before server starts, exits on failure
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 5a159882
Pipeline #37 canceled with stages
-- Swiss System Tournament API - Initial Schema
-- Run on Supabase PostgreSQL 15
-- Idempotent: safe to re-run
-- ============================================================
-- ENUM TYPES
-- ============================================================
CREATE TYPE public.user_role AS ENUM ('super_admin','org_admin','arbiter','player','spectator');
CREATE TYPE public.tournament_type AS ENUM ('swiss','round_robin','double_round_robin');
CREATE TYPE public.tournament_status AS ENUM ('draft','registration','in_progress','completed','cancelled');
CREATE TYPE public.round_status AS ENUM ('pending','paired','in_progress','completed');
CREATE TYPE public.game_result AS ENUM ('white_wins','black_wins','draw','white_forfeit','black_forfeit','double_forfeit','bye_full','bye_half','bye_zero','not_played');
CREATE TYPE public.color AS ENUM ('white','black');
CREATE TYPE public.tiebreak_type AS ENUM ('buchholz','buchholz_cut_1','buchholz_median','sonneborn_berger','direct_encounter','number_of_wins','number_of_blacks','koya','progressive_score','average_rating_opponents','performance_rating');
CREATE TYPE public.time_control_type AS ENUM ('standard','rapid','blitz','bullet');
CREATE TYPE public.audit_action AS ENUM ('create','update','delete','pair_round','unpair_round','enter_result','modify_result','generate_standings','export_trf');
CREATE TYPE public.org_membership_status AS ENUM ('active','suspended','invited');
DO $$ BEGIN
CREATE TYPE public.user_role AS ENUM ('super_admin','org_admin','arbiter','player','spectator');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.tournament_type AS ENUM ('swiss','round_robin','double_round_robin');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.tournament_status AS ENUM ('draft','registration','in_progress','completed','cancelled');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.round_status AS ENUM ('pending','paired','in_progress','completed');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.game_result AS ENUM ('white_wins','black_wins','draw','white_forfeit','black_forfeit','double_forfeit','bye_full','bye_half','bye_zero','not_played');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.color AS ENUM ('white','black');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.tiebreak_type AS ENUM ('buchholz','buchholz_cut_1','buchholz_median','sonneborn_berger','direct_encounter','number_of_wins','number_of_blacks','koya','progressive_score','average_rating_opponents','performance_rating');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.time_control_type AS ENUM ('standard','rapid','blitz','bullet');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.audit_action AS ENUM ('create','update','delete','pair_round','unpair_round','enter_result','modify_result','generate_standings','export_trf');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE public.org_membership_status AS ENUM ('active','suspended','invited');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ============================================================
-- ORGANIZATIONS
-- ============================================================
CREATE TABLE public.organizations (
CREATE TABLE IF NOT EXISTS public.organizations (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
slug text NOT NULL UNIQUE,
......@@ -38,7 +77,7 @@ CREATE TABLE public.organizations (
-- ============================================================
-- USER PROFILES (extends auth.users)
-- ============================================================
CREATE TABLE public.user_profiles (
CREATE TABLE IF NOT EXISTS public.user_profiles (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
full_name text NOT NULL,
display_name text,
......@@ -59,7 +98,7 @@ CREATE TABLE public.user_profiles (
-- ============================================================
-- ORG MEMBERSHIPS
-- ============================================================
CREATE TABLE public.org_memberships (
CREATE TABLE IF NOT EXISTS public.org_memberships (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
......@@ -75,7 +114,7 @@ CREATE TABLE public.org_memberships (
-- ============================================================
-- EVENTS
-- ============================================================
CREATE TABLE public.events (
CREATE TABLE IF NOT EXISTS public.events (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
name text NOT NULL,
......@@ -100,7 +139,7 @@ CREATE TABLE public.events (
-- ============================================================
-- TOURNAMENTS
-- ============================================================
CREATE TABLE public.tournaments (
CREATE TABLE IF NOT EXISTS public.tournaments (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
event_id uuid NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
......@@ -133,7 +172,7 @@ CREATE TABLE public.tournaments (
-- ============================================================
-- CATEGORIES
-- ============================================================
CREATE TABLE public.categories (
CREATE TABLE IF NOT EXISTS public.categories (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
tournament_id uuid NOT NULL REFERENCES public.tournaments(id) ON DELETE CASCADE,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
......@@ -153,7 +192,7 @@ CREATE TABLE public.categories (
-- ============================================================
-- TOURNAMENT PLAYERS
-- ============================================================
CREATE TABLE public.tournament_players (
CREATE TABLE IF NOT EXISTS public.tournament_players (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
tournament_id uuid NOT NULL REFERENCES public.tournaments(id) ON DELETE CASCADE,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
......@@ -187,7 +226,7 @@ CREATE TABLE public.tournament_players (
-- ============================================================
-- ROUNDS
-- ============================================================
CREATE TABLE public.rounds (
CREATE TABLE IF NOT EXISTS public.rounds (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
tournament_id uuid NOT NULL REFERENCES public.tournaments(id) ON DELETE CASCADE,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
......@@ -206,7 +245,7 @@ CREATE TABLE public.rounds (
-- ============================================================
-- PAIRINGS
-- ============================================================
CREATE TABLE public.pairings (
CREATE TABLE IF NOT EXISTS public.pairings (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
round_id uuid NOT NULL REFERENCES public.rounds(id) ON DELETE CASCADE,
tournament_id uuid NOT NULL REFERENCES public.tournaments(id) ON DELETE CASCADE,
......@@ -230,7 +269,7 @@ CREATE TABLE public.pairings (
-- ============================================================
-- STANDINGS
-- ============================================================
CREATE TABLE public.standings (
CREATE TABLE IF NOT EXISTS public.standings (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
tournament_id uuid NOT NULL REFERENCES public.tournaments(id) ON DELETE CASCADE,
round_id uuid NOT NULL REFERENCES public.rounds(id) ON DELETE CASCADE,
......@@ -257,7 +296,7 @@ CREATE TABLE public.standings (
-- ============================================================
-- AUDIT LOGS
-- ============================================================
CREATE TABLE public.audit_logs (
CREATE TABLE IF NOT EXISTS public.audit_logs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
......@@ -272,24 +311,24 @@ CREATE TABLE public.audit_logs (
-- ============================================================
-- INDEXES
-- ============================================================
CREATE INDEX idx_org_memberships_user ON public.org_memberships(user_id);
CREATE INDEX idx_org_memberships_org ON public.org_memberships(organization_id);
CREATE INDEX idx_events_org ON public.events(organization_id);
CREATE INDEX idx_tournaments_event ON public.tournaments(event_id);
CREATE INDEX idx_tournaments_org ON public.tournaments(organization_id);
CREATE INDEX idx_tournament_players_tournament ON public.tournament_players(tournament_id);
CREATE INDEX idx_tournament_players_user ON public.tournament_players(user_id);
CREATE INDEX idx_tournament_players_fide ON public.tournament_players(fide_id);
CREATE INDEX idx_rounds_tournament ON public.rounds(tournament_id);
CREATE INDEX idx_pairings_round ON public.pairings(round_id);
CREATE INDEX idx_pairings_white ON public.pairings(white_player_id);
CREATE INDEX idx_pairings_black ON public.pairings(black_player_id);
CREATE INDEX idx_pairings_tournament ON public.pairings(tournament_id);
CREATE INDEX idx_standings_tournament_round ON public.standings(tournament_id, round_id);
CREATE INDEX idx_standings_player ON public.standings(player_id);
CREATE INDEX idx_audit_logs_org ON public.audit_logs(organization_id);
CREATE INDEX idx_audit_logs_resource ON public.audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_created ON public.audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON public.org_memberships(user_id);
CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON public.org_memberships(organization_id);
CREATE INDEX IF NOT EXISTS idx_events_org ON public.events(organization_id);
CREATE INDEX IF NOT EXISTS idx_tournaments_event ON public.tournaments(event_id);
CREATE INDEX IF NOT EXISTS idx_tournaments_org ON public.tournaments(organization_id);
CREATE INDEX IF NOT EXISTS idx_tournament_players_tournament ON public.tournament_players(tournament_id);
CREATE INDEX IF NOT EXISTS idx_tournament_players_user ON public.tournament_players(user_id);
CREATE INDEX IF NOT EXISTS idx_tournament_players_fide ON public.tournament_players(fide_id);
CREATE INDEX IF NOT EXISTS idx_rounds_tournament ON public.rounds(tournament_id);
CREATE INDEX IF NOT EXISTS idx_pairings_round ON public.pairings(round_id);
CREATE INDEX IF NOT EXISTS idx_pairings_white ON public.pairings(white_player_id);
CREATE INDEX IF NOT EXISTS idx_pairings_black ON public.pairings(black_player_id);
CREATE INDEX IF NOT EXISTS idx_pairings_tournament ON public.pairings(tournament_id);
CREATE INDEX IF NOT EXISTS idx_standings_tournament_round ON public.standings(tournament_id, round_id);
CREATE INDEX IF NOT EXISTS idx_standings_player ON public.standings(player_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_org ON public.audit_logs(organization_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON public.audit_logs(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON public.audit_logs(created_at DESC);
-- ============================================================
-- ROW LEVEL SECURITY
......@@ -326,74 +365,110 @@ RETURNS boolean AS $$
);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
-- RLS Policies
-- RLS Policies (DROP IF EXISTS + CREATE for idempotency)
DROP POLICY IF EXISTS "Users see their orgs" ON public.organizations;
CREATE POLICY "Users see their orgs" ON public.organizations FOR SELECT
USING (id = ANY(public.get_user_org_ids()));
DROP POLICY IF EXISTS "Authenticated can create orgs" ON public.organizations;
CREATE POLICY "Authenticated can create orgs" ON public.organizations FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
DROP POLICY IF EXISTS "Admins update orgs" ON public.organizations;
CREATE POLICY "Admins update orgs" ON public.organizations FOR UPDATE
USING (public.user_has_role_in_org(id, ARRAY['org_admin','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "Users see memberships" ON public.org_memberships;
CREATE POLICY "Users see memberships" ON public.org_memberships FOR SELECT
USING (organization_id = ANY(public.get_user_org_ids()));
DROP POLICY IF EXISTS "Admins manage memberships" ON public.org_memberships;
CREATE POLICY "Admins manage memberships" ON public.org_memberships FOR ALL
USING (public.user_has_role_in_org(organization_id, ARRAY['org_admin','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "Users see own profile" ON public.user_profiles;
CREATE POLICY "Users see own profile" ON public.user_profiles FOR SELECT
USING (id = auth.uid());
DROP POLICY IF EXISTS "Users update own profile" ON public.user_profiles;
CREATE POLICY "Users update own profile" ON public.user_profiles FOR UPDATE
USING (id = auth.uid());
DROP POLICY IF EXISTS "Users see events in their orgs" ON public.events;
CREATE POLICY "Users see events in their orgs" ON public.events FOR SELECT
USING (organization_id = ANY(public.get_user_org_ids()));
DROP POLICY IF EXISTS "Admins manage events" ON public.events;
CREATE POLICY "Admins manage events" ON public.events FOR ALL
USING (public.user_has_role_in_org(organization_id, ARRAY['org_admin','arbiter','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "Users see tournaments" ON public.tournaments;
CREATE POLICY "Users see tournaments" ON public.tournaments FOR SELECT
USING (organization_id = ANY(public.get_user_org_ids()));
DROP POLICY IF EXISTS "Admins manage tournaments" ON public.tournaments;
CREATE POLICY "Admins manage tournaments" ON public.tournaments FOR ALL
USING (public.user_has_role_in_org(organization_id, ARRAY['org_admin','arbiter','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "Users see players" ON public.tournament_players;
CREATE POLICY "Users see players" ON public.tournament_players FOR SELECT
USING (organization_id = ANY(public.get_user_org_ids()));
DROP POLICY IF EXISTS "Admins manage players" ON public.tournament_players;
CREATE POLICY "Admins manage players" ON public.tournament_players FOR ALL
USING (public.user_has_role_in_org(organization_id, ARRAY['org_admin','arbiter','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "Users see rounds" ON public.rounds;
CREATE POLICY "Users see rounds" ON public.rounds FOR SELECT
USING (organization_id = ANY(public.get_user_org_ids()));
DROP POLICY IF EXISTS "Admins manage rounds" ON public.rounds;
CREATE POLICY "Admins manage rounds" ON public.rounds FOR ALL
USING (public.user_has_role_in_org(organization_id, ARRAY['org_admin','arbiter','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "Users see pairings" ON public.pairings;
CREATE POLICY "Users see pairings" ON public.pairings FOR SELECT
USING (organization_id = ANY(public.get_user_org_ids()));
DROP POLICY IF EXISTS "Admins manage pairings" ON public.pairings;
CREATE POLICY "Admins manage pairings" ON public.pairings FOR ALL
USING (public.user_has_role_in_org(organization_id, ARRAY['org_admin','arbiter','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "Users see standings" ON public.standings;
CREATE POLICY "Users see standings" ON public.standings FOR SELECT
USING (organization_id = ANY(public.get_user_org_ids()));
DROP POLICY IF EXISTS "Admins manage standings" ON public.standings;
CREATE POLICY "Admins manage standings" ON public.standings FOR ALL
USING (public.user_has_role_in_org(organization_id, ARRAY['org_admin','arbiter','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "Admins see audit logs" ON public.audit_logs;
CREATE POLICY "Admins see audit logs" ON public.audit_logs FOR SELECT
USING (public.user_has_role_in_org(organization_id, ARRAY['org_admin','super_admin']::public.user_role[]));
DROP POLICY IF EXISTS "System inserts audit logs" ON public.audit_logs;
CREATE POLICY "System inserts audit logs" ON public.audit_logs FOR INSERT
WITH CHECK (true);
-- ============================================================
-- REALTIME PUBLICATIONS
-- ============================================================
ALTER PUBLICATION supabase_realtime ADD TABLE public.pairings;
ALTER PUBLICATION supabase_realtime ADD TABLE public.standings;
ALTER PUBLICATION supabase_realtime ADD TABLE public.rounds;
ALTER PUBLICATION supabase_realtime ADD TABLE public.tournament_players;
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.pairings;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.standings;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.rounds;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.tournament_players;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
import { buildApp } from './app.js';
import { closeDatabase } from './config/database.js';
import { runMigrations } from './migrator.js';
async function main() {
const databaseUrl = process.env.DATABASE_URL;
if (databaseUrl) {
try {
await runMigrations(databaseUrl);
} catch (err) {
console.error('[migrate] FAILED:', err);
process.exit(1);
}
}
const app = await buildApp();
const shutdown = async (signal: string) => {
......
import postgres from 'postgres';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { createHash } from 'crypto';
export async function runMigrations(databaseUrl: string): Promise<void> {
const sql = postgres(databaseUrl, { max: 1 });
try {
await sql`
CREATE TABLE IF NOT EXISTS public._migrations (
id serial PRIMARY KEY,
name text NOT NULL UNIQUE,
hash text NOT NULL,
executed_at timestamptz DEFAULT now() NOT NULL
)
`;
const executed = await sql<{ name: string }[]>`SELECT name FROM public._migrations ORDER BY id`;
const executedNames = new Set(executed.map(r => r.name));
const migrationsDir = join(import.meta.dirname, '..', 'migrations');
const files = readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
for (const file of files) {
if (executedNames.has(file)) {
console.log(`[migrate] skip: ${file} (already executed)`);
continue;
}
const filePath = join(migrationsDir, file);
const content = readFileSync(filePath, 'utf-8');
const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
console.log(`[migrate] running: ${file}...`);
await sql.unsafe(content);
await sql`INSERT INTO public._migrations (name, hash) VALUES (${file}, ${hash})`;
console.log(`[migrate] done: ${file}`);
}
console.log('[migrate] all migrations up to date');
} finally {
await sql.end();
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment