Files
game-scoreboard/supabase/migrations/0001_init.sql
T
joshuaandClaude Opus 5.5 c89a82295b Phase 0: scaffold Next.js, Tailwind tokens, Supabase clients, env validation
Next.js 16 App Router with strict TypeScript, Tailwind 4 theme tokens from
docs/UI.md, Vitest, and the three Supabase clients. Env vars are validated
with zod in next.config.ts so dev and build fail fast with a readable error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:27:11 +02:00

191 lines
8.6 KiB
PL/PgSQL

-- Game Night Live — initial schema
-- Requires Postgres 15+ (Supabase default) for NULLS NOT DISTINCT.
create extension if not exists pgcrypto;
-- ---------- enums ----------
create type scoring_mode as enum ('judged', 'direct');
create type points_mode as enum ('raw', 'placement');
create type game_status as enum ('upcoming', 'live', 'done');
create type scoreboard_visibility as enum ('visible', 'frozen', 'hidden');
create type criterion_input as enum ('blocks', 'number');
create type judge_source as enum ('nominated', 'open_link');
-- token helper: 32 hex chars = 128 bits
create or replace function new_token() returns text
language sql volatile as $$ select encode(gen_random_bytes(16), 'hex') $$;
-- ---------- tables ----------
create table events (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
name text not null check (length(name) between 1 and 120),
event_date date,
scoreboard_token text not null unique default new_token(),
scoreboard_visibility scoreboard_visibility not null default 'visible',
frozen_snapshot jsonb,
current_game_id uuid,
reveal_active boolean not null default false,
reveal_count int not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table teams (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references events(id) on delete cascade,
name text not null check (length(name) between 1 and 60),
color text not null default '#3A86FF' check (color ~ '^#[0-9A-Fa-f]{6}$'),
sort_order int not null default 0,
created_at timestamptz not null default now()
);
create index on teams(event_id);
create table games (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references events(id) on delete cascade,
title text not null check (length(title) between 1 and 120),
description text,
sort_order int not null default 0,
scoring_mode scoring_mode not null default 'direct',
points_mode points_mode not null default 'raw',
multiplier numeric(6,2) not null default 1 check (multiplier >= 0),
placement_points int[] not null default '{10,7,5,3}',
status game_status not null default 'upcoming',
admin_notes text,
judge_notes text,
open_token text not null unique default new_token(),
open_link_enabled boolean not null default false,
judging_locked boolean not null default false,
spotlight_team_id uuid references teams(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index on games(event_id, sort_order);
alter table events
add constraint events_current_game_fk
foreign key (current_game_id) references games(id) on delete set null;
create table criteria (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references games(id) on delete cascade,
label text not null check (length(label) between 1 and 60),
description text,
input_type criterion_input not null default 'blocks',
min_value numeric(8,2) not null default 1,
max_value numeric(8,2) not null default 5,
step numeric(8,2) not null default 1 check (step > 0),
weight numeric(6,2) not null default 1 check (weight >= 0),
sort_order int not null default 0,
check (max_value > min_value),
-- blocks render one button per step; keep it tappable
check (input_type <> 'blocks' or (max_value - min_value) / step <= 10)
);
create index on criteria(game_id, sort_order);
create table judges (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references games(id) on delete cascade,
name text not null check (length(name) between 1 and 60),
token text not null unique default new_token(),
source judge_source not null default 'nominated',
revoked boolean not null default false,
created_at timestamptz not null default now()
);
create index on judges(game_id);
create table scores (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references games(id) on delete cascade,
team_id uuid not null references teams(id) on delete cascade,
criterion_id uuid not null references criteria(id) on delete cascade,
judge_id uuid references judges(id) on delete cascade, -- NULL = admin score / override
value numeric(8,2) not null,
updated_at timestamptz not null default now(),
constraint scores_one_per_cell unique nulls not distinct (team_id, criterion_id, judge_id)
);
create index on scores(game_id);
create table adjustments (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references events(id) on delete cascade,
team_id uuid not null references teams(id) on delete cascade,
game_id uuid references games(id) on delete set null,
points numeric(8,2) not null check (points <> 0),
reason text,
created_at timestamptz not null default now()
);
create index on adjustments(event_id);
-- ---------- integrity triggers ----------
-- score must be in range, on step, and criterion must belong to the score's game
create or replace function validate_score() returns trigger
language plpgsql as $$
declare c criteria%rowtype;
begin
select * into c from criteria where id = new.criterion_id;
if c.game_id <> new.game_id then
raise exception 'criterion does not belong to game';
end if;
if new.value < c.min_value or new.value > c.max_value then
raise exception 'value % out of range [%, %]', new.value, c.min_value, c.max_value;
end if;
if mod(new.value - c.min_value, c.step) <> 0 then
raise exception 'value % not on step %', new.value, c.step;
end if;
if new.judge_id is not null and
(select game_id from judges where id = new.judge_id) <> new.game_id then
raise exception 'judge does not belong to game';
end if;
new.updated_at := now();
return new;
end $$;
create trigger scores_validate
before insert or update on scores
for each row execute function validate_score();
create or replace function touch_updated_at() returns trigger
language plpgsql as $$ begin new.updated_at := now(); return new; end $$;
create trigger events_touch before update on events for each row execute function touch_updated_at();
create trigger games_touch before update on games for each row execute function touch_updated_at();
-- ---------- RLS ----------
-- Only the authenticated owner can touch anything. Anon has NO policies.
-- Public + judge access goes through server route handlers using the service role.
create or replace function is_event_owner(eid uuid) returns boolean
language sql stable security definer set search_path = public as $$
select exists (select 1 from events where id = eid and owner_id = auth.uid())
$$;
create or replace function game_event(gid uuid) returns uuid
language sql stable security definer set search_path = public as $$
select event_id from games where id = gid
$$;
alter table events enable row level security;
alter table teams enable row level security;
alter table games enable row level security;
alter table criteria enable row level security;
alter table judges enable row level security;
alter table scores enable row level security;
alter table adjustments enable row level security;
create policy owner_all on events for all to authenticated
using (owner_id = auth.uid()) with check (owner_id = auth.uid());
create policy owner_all on teams for all to authenticated
using (is_event_owner(event_id)) with check (is_event_owner(event_id));
create policy owner_all on games for all to authenticated
using (is_event_owner(event_id)) with check (is_event_owner(event_id));
create policy owner_all on adjustments for all to authenticated
using (is_event_owner(event_id)) with check (is_event_owner(event_id));
create policy owner_all on criteria for all to authenticated
using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));
create policy owner_all on judges for all to authenticated
using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));
create policy owner_all on scores for all to authenticated
using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));