teecup/001_initial_schema.sql

411 lines
19 KiB
MySQL
Raw Normal View History

2026-07-16 07:18:01 +02:00
-- =====================================================================
-- TeeCup — initielt databaseskjema (migrasjon 001)
-- =====================================================================
-- Realiserer arkitektur-beslutningene:
-- ADR-001 tenant = organisasjon -> organization_id på alle domenetabeller
-- ADR-002 bruker != medlemskap -> app_user / organization_membership
-- ADR-003 shared schema + RLS -> policy på hver domenetabell
-- ADR-005 allowance = konfig -> session.allowance_override (jsonb)
--
-- Kjøremiljø: PostgreSQL 15+ (gen_random_uuid(), FORCE ROW LEVEL SECURITY,
-- og security_invoker-views for at RLS skal gjelde gjennom viewet).
--
-- VIKTIG DRIFTSKRAV (ADR-003):
-- Applikasjonen MÅ koble til som en rolle UTEN superuser/BYPASSRLS, og MÅ
-- sette organisasjonskonteksten per transaksjon:
-- SET LOCAL app.current_org = '<organisasjonens uuid>';
-- Uten dette returnerer RLS-policyene null rader (trygg standard: se ingenting).
-- =====================================================================
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS citext; -- case-insensitiv e-post
-- ---------------------------------------------------------------------
-- Enum-typer (stabile, små mengder)
-- ---------------------------------------------------------------------
CREATE TYPE team_side AS ENUM ('a', 'b');
CREATE TYPE hole_scope AS ENUM ('full_18', 'front_9', 'back_9');
CREATE TYPE tournament_status AS ENUM ('draft', 'active', 'completed', 'archived');
CREATE TYPE course_source AS ENUM ('official', 'custom');
CREATE TYPE rating_scope AS ENUM ('full_18', 'front_9', 'back_9');
-- Format holdes som tekst med CHECK (ikke enum), slik at nye varianter kan
-- legges til uten ALTER TYPE. Verdiene speiler Format-enumen i handicap_engine.py.
-- Selve prosenttildelingen (allowance) er konfig, ikke låst her (ADR-005).
-- =====================================================================
-- 1. Identitet og tenancy (ADR-002)
-- =====================================================================
-- Global identitet. IKKE organisasjonsavgrenset. Ingen RLS på org-nøkkel her.
CREATE TABLE app_user (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email citext, -- ev. NULL for magic-link-only e.l.
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE organization (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text UNIQUE, -- f.eks. subdomene/URL-vennlig
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Bindeleddet bruker <-> organisasjon. Én bruker kan tilhøre flere org (ADR-002).
CREATE TABLE organization_membership (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member'
CHECK (role IN ('owner', 'admin', 'member')),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, user_id)
);
-- =====================================================================
-- 2. Personer (spiller-pool på organisasjonsnivå)
-- =====================================================================
-- En spiller er en person i organisasjonens register. Mange golfere i en
-- vennegjeng/klubb har ikke egen konto, derfor er player adskilt fra app_user;
-- user_id kobler valgfritt en spiller til en innlogget bruker.
CREATE TABLE player (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
user_id uuid REFERENCES app_user(id) ON DELETE SET NULL,
display_name text NOT NULL,
-- Gjeldende/standard handicap-indeks. Snapshottes per turnering i team_roster.
handicap_index numeric(4,1),
gender text CHECK (gender IN ('m', 'f', 'x')), -- for kjønnsspesifikke tee-ratinger
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, id) -- for sammensatte FK-er
);
-- =====================================================================
-- 3. Baner (ADR-004: offisielle hentes fra teeoff via API, custom lages lokalt)
-- =====================================================================
CREATE TABLE course (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
name text NOT NULL,
source course_source NOT NULL DEFAULT 'custom',
-- Referanse til offisiell bane i teeoff_db (kun når source = 'official').
-- Ren referanse — ingen fysisk kryss-database-FK (ADR-004).
external_course_ref text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, id),
CHECK (source <> 'official' OR external_course_ref IS NOT NULL)
);
-- Hull med par og stroke index (SI). SI er normalt konstant per bane.
CREATE TABLE hole (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
course_id uuid NOT NULL,
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
par smallint NOT NULL CHECK (par BETWEEN 3 AND 6),
stroke_index smallint NOT NULL CHECK (stroke_index BETWEEN 1 AND 18),
FOREIGN KEY (organization_id, course_id)
REFERENCES course(organization_id, id) ON DELETE CASCADE,
UNIQUE (course_id, hole_number),
UNIQUE (course_id, stroke_index) -- SI må være unik per bane
);
-- Tee (utslagssted). Rating/slope varierer per tee.
CREATE TABLE tee (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
course_id uuid NOT NULL,
name text NOT NULL, -- f.eks. "Gul", "Rød", "Hvit"
gender text CHECK (gender IN ('m', 'f', 'x')),
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, course_id)
REFERENCES course(organization_id, id) ON DELETE CASCADE,
UNIQUE (organization_id, id)
);
-- WHS har separate ratinger for fulle 18, front 9 og back 9. Derav egen tabell
-- slik at 9-hulls økter (front/back) bruker riktig rating (viktig for 9-hulls
-- course handicap).
CREATE TABLE tee_rating (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tee_id uuid NOT NULL,
scope rating_scope NOT NULL,
course_rating numeric(4,1) NOT NULL,
slope_rating smallint NOT NULL CHECK (slope_rating BETWEEN 55 AND 155),
par smallint NOT NULL, -- par for det aktuelle omfanget
FOREIGN KEY (organization_id, tee_id)
REFERENCES tee(organization_id, id) ON DELETE CASCADE,
UNIQUE (tee_id, scope)
);
-- =====================================================================
-- 4. Turneringsstruktur
-- =====================================================================
CREATE TABLE tournament (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
name text NOT NULL,
status tournament_status NOT NULL DEFAULT 'draft',
start_date date,
created_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, id)
);
-- Lag i en turnering. Ryder Cup = 2 lag, men modellen låser ikke antallet.
CREATE TABLE team (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_id uuid NOT NULL,
name text NOT NULL, -- f.eks. "Team Europa"
color text, -- for UI
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_id)
REFERENCES tournament(organization_id, id) ON DELETE CASCADE,
UNIQUE (organization_id, id),
UNIQUE (tournament_id, name)
);
-- SPILLER-POOL: alle spillere som er tatt ut på et lag for turneringen.
-- Reserver er ganske enkelt rader her som ikke får en match_participant i en
-- gitt økt. handicap_index_snapshot fryser indeksen for reproduserbare resultater.
CREATE TABLE team_roster (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
team_id uuid NOT NULL,
player_id uuid NOT NULL,
handicap_index_snapshot numeric(4,1), -- fryst ved uttak
is_captain boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, team_id)
REFERENCES team(organization_id, id) ON DELETE CASCADE,
FOREIGN KEY (organization_id, player_id)
REFERENCES player(organization_id, id) ON DELETE RESTRICT,
UNIQUE (organization_id, id),
UNIQUE (team_id, player_id) -- en spiller én gang per lag
);
-- ØKT (session): ett format, ett hullomfang, én allowance-konfig. En turnering
-- er en ORDNET sekvens av økter (foursome -> fourball -> singles ...).
CREATE TABLE session (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_id uuid NOT NULL,
sequence smallint NOT NULL, -- rekkefølge i turneringen (1,2,3...)
name text, -- f.eks. "Lørdag formiddag"
format text NOT NULL CHECK (format IN
('singles','fourball','foursome','greensome','scramble_2','scramble_4')),
hole_config hole_scope NOT NULL DEFAULT 'full_18',
course_id uuid NOT NULL,
points_per_match numeric(3,1) NOT NULL DEFAULT 1.0,
-- ADR-005: NULL => bruk motorens standard-allowance for formatet.
-- Ellers en overstyring, f.eks. {"type":"per_player","percentage":0.85}.
allowance_override jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_id)
REFERENCES tournament(organization_id, id) ON DELETE CASCADE,
FOREIGN KEY (organization_id, course_id)
REFERENCES course(organization_id, id) ON DELETE RESTRICT,
UNIQUE (organization_id, id),
UNIQUE (tournament_id, sequence)
);
-- MATCH: én kamp i en økt, side a mot side b. Poeng caches når resultatet er
-- avgjort (beregnes av handicap_engine; SQL summerer bare de cachede poengene).
CREATE TABLE match (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
session_id uuid NOT NULL,
sequence smallint NOT NULL, -- kamp-nr innen økten
team_a_id uuid NOT NULL, -- hvilket lag er side a
team_b_id uuid NOT NULL, -- hvilket lag er side b
-- Cachede resultatfelt (kilde: motoren). NULL = ikke avgjort ennå.
status_text text, -- f.eks. "3&2 (A)", "AS"
points_side_a numeric(3,1),
points_side_b numeric(3,1),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, session_id)
REFERENCES session(organization_id, id) ON DELETE CASCADE,
FOREIGN KEY (organization_id, team_a_id)
REFERENCES team(organization_id, id) ON DELETE RESTRICT,
FOREIGN KEY (organization_id, team_b_id)
REFERENCES team(organization_id, id) ON DELETE RESTRICT,
UNIQUE (organization_id, id),
UNIQUE (session_id, sequence),
CHECK (team_a_id <> team_b_id)
);
-- Hvilke spillere fra poolen som faktisk spiller matchen, og på hvilken side.
-- 1 deltaker per side i singles, 2 i foursome/fourball/greensome, N i scramble.
-- (Antallet vs. format valideres i applikasjonslaget.)
CREATE TABLE match_participant (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
match_id uuid NOT NULL,
team_side team_side NOT NULL,
team_roster_id uuid NOT NULL, -- peker inn i poolen
tee_id uuid NOT NULL, -- tee spilleren bruker (mixed tees støttes)
-- Cachede handicap-felt (kilde: motoren, ut fra indeks-snapshot + tee + allowance).
course_handicap smallint,
playing_handicap smallint,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, match_id)
REFERENCES match(organization_id, id) ON DELETE CASCADE,
FOREIGN KEY (organization_id, team_roster_id)
REFERENCES team_roster(organization_id, id) ON DELETE RESTRICT,
FOREIGN KEY (organization_id, tee_id)
REFERENCES tee(organization_id, id) ON DELETE RESTRICT,
UNIQUE (organization_id, id),
UNIQUE (match_id, team_roster_id) -- en spiller kan ikke spille to plasser i samme match
);
-- =====================================================================
-- 5. Score (kilde-sannhet: brutto slag per hull)
-- =====================================================================
-- Netto beregnes av motoren (brutto - tildelte slag på hullet). Vi lagrer ikke
-- netto; det er avledet. Matchresultat caches på match-raden.
--
-- To scoringsmoduser i samme tabell:
-- * Individuell ball (singles, fourball): match_participant_id satt.
-- * Delt ball (foursome, greensome, scramble): match_participant_id = NULL,
-- scoren tilhører HELE siden (team_side).
-- Hvilken modus som gjelder styres av øktens format (håndheves i app-laget).
CREATE TABLE hole_score (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
match_id uuid NOT NULL,
team_side team_side NOT NULL,
match_participant_id uuid, -- NULL for delt-ball-formater
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
gross_strokes smallint NOT NULL CHECK (gross_strokes BETWEEN 1 AND 20),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, match_id)
REFERENCES match(organization_id, id) ON DELETE CASCADE,
FOREIGN KEY (organization_id, match_participant_id)
REFERENCES match_participant(organization_id, id) ON DELETE CASCADE
);
-- Unik score per spiller per hull (individuell ball) ...
CREATE UNIQUE INDEX hole_score_player_unique
ON hole_score (match_participant_id, hole_number)
WHERE match_participant_id IS NOT NULL;
-- ... og unik score per side per hull (delt ball).
CREATE UNIQUE INDEX hole_score_side_unique
ON hole_score (match_id, team_side, hole_number)
WHERE match_participant_id IS NULL;
-- =====================================================================
-- 6. Indekser (RLS-predikat og FK-oppslag; Postgres indekserer ikke FK automatisk)
-- =====================================================================
CREATE INDEX ON player (organization_id);
CREATE INDEX ON course (organization_id);
CREATE INDEX ON hole (organization_id, course_id);
CREATE INDEX ON tee (organization_id, course_id);
CREATE INDEX ON tee_rating (organization_id, tee_id);
CREATE INDEX ON tournament (organization_id);
CREATE INDEX ON team (organization_id, tournament_id);
CREATE INDEX ON team_roster (organization_id, team_id);
CREATE INDEX ON team_roster (organization_id, player_id);
CREATE INDEX ON session (organization_id, tournament_id);
CREATE INDEX ON match (organization_id, session_id);
CREATE INDEX ON match_participant (organization_id, match_id);
CREATE INDEX ON hole_score (organization_id, match_id);
-- =====================================================================
-- 7. Row-Level Security (ADR-003)
-- =====================================================================
-- Standard org-isolasjonspolicy på hver domenetabell. Konteksten settes av
-- applikasjonen via: SET LOCAL app.current_org = '<uuid>';
-- current_setting(..., true) gir NULL (ikke feil) når konteksten mangler ->
-- policyen slipper da ingen rader gjennom (trygg standard).
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'player','course','hole','tee','tee_rating',
'tournament','team','team_roster','session',
'match','match_participant','hole_score'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t);
EXECUTE format($p$
CREATE POLICY org_isolation ON %I
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
$p$, t);
END LOOP;
END $$;
-- Organisasjonsraden selv: synlig kun for den aktive konteksten.
ALTER TABLE organization ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization FORCE ROW LEVEL SECURITY;
CREATE POLICY org_self ON organization
USING (id = current_setting('app.current_org', true)::uuid);
-- MERK: app_user og organization_membership håndteres av auth-laget (en bruker
-- må kunne se sine EGNE medlemskap for å velge organisasjon). Egne policyer for
-- disse defineres sammen med autentiseringsdesignet, ikke her.
-- =====================================================================
-- 8. Leaderboard-view (SQL summerer cachede matchpoeng; motoren beregner dem)
-- =====================================================================
-- security_invoker => viewet kjører med den spørrende rollens RLS, ikke eierens.
-- Uten dette kunne standings lekke matcher på tvers av organisasjoner.
CREATE VIEW tournament_standings
WITH (security_invoker = true) AS
SELECT
t.organization_id,
t.tournament_id,
t.id AS team_id,
t.name AS team_name,
COALESCE(SUM(
CASE WHEN m.team_a_id = t.id THEN m.points_side_a
WHEN m.team_b_id = t.id THEN m.points_side_b
ELSE 0 END
), 0) AS points
FROM team t
LEFT JOIN match m
ON m.organization_id = t.organization_id
AND (m.team_a_id = t.id OR m.team_b_id = t.id)
GROUP BY t.organization_id, t.tournament_id, t.id, t.name;
-- =====================================================================
-- 9. updated_at-trigger (valgfritt mønster — vist på to tabeller)
-- =====================================================================
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_tournament_updated
BEFORE UPDATE ON tournament
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_match_updated
BEFORE UPDATE ON match
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- (Samme mønster kan legges på øvrige tabeller med updated_at.)