-- ===================================================================== -- 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 = ''; -- 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 = ''; -- 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.)