358 lines
14 KiB
PL/PgSQL
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;
|