-- R1 Partien und Wertung: score_sheets, score_categories, plays, play_players, play_scores, -- dazu Sicht auf Mitspieler-Profile und Statistik. create table public.score_sheets ( id uuid primary key default gen_random_uuid(), owner_id uuid not null references public.profiles (id) on delete cascade, game_id uuid references public.games (id) on delete set null, name text not null check (char_length(name) between 1 and 60), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index score_sheets_owner_id_idx on public.score_sheets (owner_id); create table public.score_categories ( id uuid primary key default gen_random_uuid(), sheet_id uuid not null references public.score_sheets (id) on delete cascade, name text not null check (char_length(name) between 1 and 40), position smallint not null check (position >= 0), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (sheet_id, position) ); -- owner_id wird leer, wenn der Ersteller sein Konto löscht, die Partie aber für andere Mitspieler -- erhalten bleibt (Entscheidung zum Konto löschen). create table public.plays ( id uuid primary key default gen_random_uuid(), owner_id uuid references public.profiles (id) on delete set null, game_id uuid not null references public.games (id), variant_id uuid, sheet_id uuid references public.score_sheets (id) on delete set null, played_at timestamptz not null, duration_minutes integer check (duration_minutes >= 0), note text check (char_length(note) <= 1000), -- Von der App beim Erfassen vergeben; macht den Abgleich nach Offline-Erfassung wiederholbar. client_id uuid not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (owner_id, client_id), foreign key (variant_id, game_id) references public.game_variants (id, game_id) ); create index plays_owner_id_idx on public.plays (owner_id, played_at desc); create index plays_game_id_idx on public.plays (game_id); -- Eine Zeile je Mitspieler. Gast: profile_id und link_status 'none', Name in guest_name. -- Gelöschter Nutzer: profile_id und guest_name leer, link_status 'none'. create table public.play_players ( id uuid primary key default gen_random_uuid(), play_id uuid not null references public.plays (id) on delete cascade, profile_id uuid references public.profiles (id) on delete set null, guest_name text check (char_length(guest_name) between 1 and 30), link_status text not null default 'none' check (link_status in ('none', 'pending', 'confirmed', 'declined')), place smallint check (place >= 1), total_score numeric, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint play_players_link_consistent check (profile_id is not null or link_status = 'none'), constraint play_players_guest_or_user check (not (profile_id is not null and guest_name is not null)) ); create index play_players_play_id_idx on public.play_players (play_id); create index play_players_profile_id_idx on public.play_players (profile_id); create table public.play_scores ( id uuid primary key default gen_random_uuid(), play_player_id uuid not null references public.play_players (id) on delete cascade, category_id uuid not null references public.score_categories (id) on delete cascade, value numeric not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (play_player_id, category_id) ); create index play_scores_category_id_idx on public.play_scores (category_id); create trigger set_updated_at before update on public.score_sheets for each row execute function public.set_updated_at(); create trigger set_updated_at before update on public.score_categories for each row execute function public.set_updated_at(); create trigger set_updated_at before update on public.plays for each row execute function public.set_updated_at(); create trigger set_updated_at before update on public.play_players for each row execute function public.set_updated_at(); create trigger set_updated_at before update on public.play_scores for each row execute function public.set_updated_at(); -- Hilfsfunktionen für die Policies. Sie laufen mit erhöhten Rechten, damit sich die Policies -- von plays und play_players nicht gegenseitig endlos aufrufen. create or replace function public.is_play_owner(p_play_id uuid) returns boolean language sql stable security definer set search_path = '' as $$ select exists ( select 1 from public.plays where id = p_play_id and owner_id = (select auth.uid()) ); $$; -- Lesen darf der Ersteller sowie jeder eingeladene Mitspieler (offen oder bestätigt), -- damit er die Einladung einordnen und bestätigen kann. create or replace function public.can_read_play(p_play_id uuid) returns boolean language sql stable security definer set search_path = '' as $$ select exists ( select 1 from public.plays where id = p_play_id and owner_id = (select auth.uid()) ) or exists ( select 1 from public.play_players where play_id = p_play_id and profile_id = (select auth.uid()) and link_status in ('pending', 'confirmed') ); $$; create or replace function public.play_id_of_player(p_play_player_id uuid) returns uuid language sql stable security definer set search_path = '' as $$ select play_id from public.play_players where id = p_play_player_id; $$; revoke all on function public.is_play_owner(uuid), public.can_read_play(uuid), public.play_id_of_player(uuid) from public, anon; grant execute on function public.is_play_owner(uuid), public.can_read_play(uuid), public.play_id_of_player(uuid) to authenticated; -- Höchstens 20 Mitspieler je Partie (O10). create or replace function public.play_players_limit() returns trigger language plpgsql security definer set search_path = '' as $$ begin if (select count(*) from public.play_players where play_id = new.play_id) >= 20 then raise exception 'play_players_limit_exceeded'; end if; return new; end; $$; create trigger play_players_limit before insert on public.play_players for each row execute function public.play_players_limit(); -- Schützt die Verknüpfung: Der Ersteller ändert Platz, Punkte und Gastname, aber nie, wer -- verknüpft ist. Der eingeladene Nutzer darf nur antworten (pending -> confirmed/declined). -- Serverseitige Aufrufe ohne Nutzer (service_role) sind ausgenommen. create or replace function public.play_players_guard() returns trigger language plpgsql set search_path = '' as $$ declare uid uuid := (select auth.uid()); begin if uid is null then return new; end if; if new.play_id <> old.play_id then raise exception 'play_change_not_allowed'; end if; if public.is_play_owner(old.play_id) then if new.profile_id is distinct from old.profile_id or new.link_status is distinct from old.link_status then raise exception 'link_change_not_allowed'; end if; else if old.link_status <> 'pending' or new.link_status not in ('confirmed', 'declined') or (to_jsonb(new) - 'link_status' - 'updated_at' - 'created_at') is distinct from (to_jsonb(old) - 'link_status' - 'updated_at' - 'created_at') then raise exception 'invitation_answer_only'; end if; end if; return new; end; $$; create trigger play_players_guard before update on public.play_players for each row execute function public.play_players_guard(); -- Konto löschen: Partien ohne andere beteiligte Nutzer verschwinden, alle anderen bleiben. -- Der gelöschte Nutzer wird in fremden Partien anonym (Anzeige: „Gelöschter Nutzer“). create or replace function public.handle_profile_delete() returns trigger language plpgsql security definer set search_path = '' as $$ begin delete from public.plays p where ( p.owner_id = old.id and not exists ( select 1 from public.play_players pp where pp.play_id = p.id and pp.profile_id is not null and pp.profile_id <> old.id and pp.link_status in ('pending', 'confirmed') ) ) or p.game_id in (select g.id from public.games g where g.owner_id = old.id); update public.play_players set profile_id = null, link_status = 'none', guest_name = null where profile_id = old.id; return old; end; $$; revoke all on function public.handle_profile_delete() from public, anon, authenticated; create trigger on_profile_delete before delete on public.profiles for each row execute function public.handle_profile_delete(); -- Rechte und RLS alter table public.score_sheets enable row level security; alter table public.score_categories enable row level security; alter table public.plays enable row level security; alter table public.play_players enable row level security; alter table public.play_scores enable row level security; revoke all on public.score_sheets, public.score_categories, public.plays, public.play_players, public.play_scores from anon, authenticated; grant select, insert, update, delete on public.score_sheets, public.score_categories, public.plays, public.play_players, public.play_scores to authenticated; -- Wertungsbögen: privat je Nutzer (O9). create policy score_sheets_own on public.score_sheets for all to authenticated using (owner_id = (select auth.uid())) with check ( owner_id = (select auth.uid()) and (game_id is null or exists (select 1 from public.games g where g.id = game_id)) ); create policy score_categories_own on public.score_categories for all to authenticated using (exists (select 1 from public.score_sheets s where s.id = sheet_id and s.owner_id = (select auth.uid()))) with check (exists (select 1 from public.score_sheets s where s.id = sheet_id and s.owner_id = (select auth.uid()))); -- Partien create policy plays_select on public.plays for select to authenticated using (public.can_read_play(id)); create policy plays_insert on public.plays for insert to authenticated with check ( owner_id = (select auth.uid()) and exists (select 1 from public.games g where g.id = game_id) and (sheet_id is null or exists (select 1 from public.score_sheets s where s.id = sheet_id)) ); create policy plays_update on public.plays for update to authenticated using (owner_id = (select auth.uid())) with check ( owner_id = (select auth.uid()) and exists (select 1 from public.games g where g.id = game_id) and (sheet_id is null or exists (select 1 from public.score_sheets s where s.id = sheet_id)) ); create policy plays_delete on public.plays for delete to authenticated using (owner_id = (select auth.uid())); -- Mitspieler: neue Zeilen nur als Gast, als man selbst (bestätigt) oder als Einladung (offen). create policy play_players_select on public.play_players for select to authenticated using (public.can_read_play(play_id) or profile_id = (select auth.uid())); create policy play_players_insert on public.play_players for insert to authenticated with check ( public.is_play_owner(play_id) and ( (profile_id is null and link_status = 'none') or (profile_id = (select auth.uid()) and link_status = 'confirmed') or (profile_id is not null and profile_id <> (select auth.uid()) and link_status = 'pending') ) ); create policy play_players_update on public.play_players for update to authenticated using (public.is_play_owner(play_id) or profile_id = (select auth.uid())) with check (public.is_play_owner(play_id) or profile_id = (select auth.uid())); create policy play_players_delete on public.play_players for delete to authenticated using (public.is_play_owner(play_id) or profile_id = (select auth.uid())); -- Einzelwerte: lesen wie die Partie, schreiben nur der Ersteller. create policy play_scores_select on public.play_scores for select to authenticated using (public.can_read_play(public.play_id_of_player(play_player_id))); create policy play_scores_write on public.play_scores for all to authenticated using (public.is_play_owner(public.play_id_of_player(play_player_id))) with check (public.is_play_owner(public.play_id_of_player(play_player_id))); -- Profile von Mitspielern: Name, Avatar und (wenn freigegeben) Stadt, nur für Nutzer, die eine -- gemeinsame lesbare Partie haben. Es gibt keine Suche über alle Nutzer (Entscheidung O3). create view public.public_profiles as select p.id, p.display_name, p.avatar_path, case when p.city_visible then p.city end as city from public.profiles p where p.deleted_at is null and ( p.id = (select auth.uid()) or exists ( select 1 from public.play_players pp where pp.profile_id = p.id and pp.link_status in ('pending', 'confirmed') and public.can_read_play(pp.play_id) ) ); revoke all on public.public_profiles from anon, authenticated; grant select on public.public_profiles to authenticated; -- Statistik je Nutzer und Spiel. Siegquote = Anteil der Partien mit Platz 1 unter den Partien -- mit Ergebnis; ein geteilter erster Platz zählt als Sieg. Nur bestätigte Teilnahmen zählen. create view public.my_game_stats with (security_invoker = true) as select pp.profile_id as user_id, p.game_id, count(*) as play_count, coalesce(sum(p.duration_minutes), 0) as total_minutes, count(*) filter (where pp.place = 1) as wins, count(*) filter (where pp.place is not null) as plays_with_result, case when count(*) filter (where pp.place is not null) = 0 then null else round( count(*) filter (where pp.place = 1)::numeric / count(*) filter (where pp.place is not null), 4) end as win_rate from public.play_players pp join public.plays p on p.id = pp.play_id where pp.profile_id = (select auth.uid()) and pp.link_status = 'confirmed' group by pp.profile_id, p.game_id; revoke all on public.my_game_stats from anon, authenticated; grant select on public.my_game_stats to authenticated;