Shuffle/supabase/migrations/20261102100400_plays.sql
cschlaefke 317634dac5
Some checks are pending
CI / flutter (push) Waiting to run
CI / supabase (push) Waiting to run
CI / supabase-tests (push) Waiting to run
chore: initial project kit
2026-11-02 12:14:09 +01:00

358 lines
14 KiB
PL/PgSQL

-- 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;