396 lines
18 KiB
PL/PgSQL
396 lines
18 KiB
PL/PgSQL
-- KAT-04 / PR 13a: Fehlendes Spiel melden (Spec docs/specs/13-fehlendes-spiel-melden.md, Abschnitte 3 und 4).
|
|
-- Nur additiv. Enthält:
|
|
-- strip_leading_article, normalize_game_title Titel-Normalisierung über search_normalize (Spec 3.3)
|
|
-- report_day Berliner Kalendertag (Tageslimit, Krit. 7)
|
|
-- game_reports.result_seen_at Ergebnishinweis weggeklickt (Spec-F5)
|
|
-- Spaltenrechte und Policies auf game_reports Anlegen eigener Meldungen, Wegklicken des Hinweises
|
|
-- game_reports_daily_limit Trigger: höchstens 10 Nutzer-Meldungen je Berliner Kalendertag
|
|
-- find_similar_catalog_games RPC: sehr ähnliche Katalogspiele (Krit. 2)
|
|
-- report_missing_game RPC: Meldung + vorläufiger Eintrag + optional Listeneintrag
|
|
-- adopt_report_target RPC (security definer): Wechsel auf den Zieleintrag (Krit. 5)
|
|
-- Rechte-Regel (Spec #2 3.2): jede Funktion zuerst allen entziehen, danach nur die Grants aus Spec 4.1.
|
|
-- editions_cleared und admin_resolve_report stammen aus 2c (kein create or replace hier).
|
|
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- 1. Normalisierung (Spec 3.3). search_normalize (3a) entfernt nie Artikel; das tun nur diese beiden.
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- Erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner). Entfernt
|
|
-- einmal ein führendes Artikelwort samt Leerzeichen; ein Titel, der nur aus dem Artikel besteht
|
|
-- ("das"), hat kein folgendes Leerzeichen und bleibt unverändert.
|
|
create function public.strip_leading_article(t text)
|
|
returns text
|
|
language sql
|
|
immutable
|
|
parallel safe
|
|
set search_path = ''
|
|
as $$
|
|
select regexp_replace(t,
|
|
'^(?:der|die|das|den|dem|des|ein|eine|einen|einem|einer|eines|the|a|an) ', '')
|
|
$$;
|
|
|
|
create function public.normalize_game_title(t text)
|
|
returns text
|
|
language sql
|
|
immutable
|
|
parallel safe
|
|
set search_path = ''
|
|
as $$
|
|
select coalesce(
|
|
nullif(public.strip_leading_article(public.search_normalize(t)), ''),
|
|
nullif(public.search_normalize(t), ''),
|
|
lower(btrim(t)))
|
|
$$;
|
|
|
|
-- Berliner Kalendertag eines Zeitpunkts. Eigene Funktion, damit pgTAP die Tagesgrenze mit festen
|
|
-- Zeitstempeln prüfen kann. Rein rechnend; authenticated braucht das Recht, weil der Trigger
|
|
-- game_reports_daily_limit als security invoker mit der Rolle des Einfügenden läuft.
|
|
create function public.report_day(p_at timestamptz)
|
|
returns date
|
|
language sql
|
|
immutable
|
|
set search_path = ''
|
|
as $$
|
|
select (p_at at time zone 'Europe/Berlin')::date
|
|
$$;
|
|
|
|
revoke execute on function public.strip_leading_article(text) from public, anon, authenticated, service_role;
|
|
revoke execute on function public.normalize_game_title(text) from public, anon, authenticated, service_role;
|
|
revoke execute on function public.report_day(timestamptz) from public, anon, authenticated, service_role;
|
|
grant execute on function public.strip_leading_article(text) to authenticated;
|
|
grant execute on function public.normalize_game_title(text) to authenticated;
|
|
grant execute on function public.report_day(timestamptz) to authenticated;
|
|
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- 2. game_reports: neue Spalte, Spaltenrechte, Policies (Spec 3.2)
|
|
-- ---------------------------------------------------------------------------------------------
|
|
alter table public.game_reports
|
|
add column result_seen_at timestamptz,
|
|
add constraint game_reports_result_seen_check check (result_seen_at is null or status <> 'open');
|
|
|
|
-- Bewusst gegenüber 2c erweitert: Nutzer legen eigene Meldungen an und ändern an eigenen, bereits
|
|
-- bearbeiteten Meldungen genau eine Spalte. Nicht schreibbar: id, origin, status, bgg_id,
|
|
-- target_game_id, resolved_at, editions_cleared, created_at, updated_at (42501); die Datenbank
|
|
-- setzt origin = 'user', status = 'open' und created_at = now() selbst. Kein delete-Recht.
|
|
grant insert (owner_id, title, publisher_name, year, link, provisional_game_id)
|
|
on public.game_reports to authenticated;
|
|
grant update (result_seen_at) on public.game_reports to authenticated;
|
|
|
|
create policy game_reports_insert_own on public.game_reports
|
|
for insert to authenticated
|
|
with check (
|
|
owner_id = (select auth.uid())
|
|
and origin = 'user'
|
|
and status = 'open'
|
|
and target_game_id is null
|
|
and provisional_game_id is not null
|
|
and exists (
|
|
select 1 from public.games g
|
|
where g.id = provisional_game_id
|
|
and g.visibility = 'private'
|
|
and g.owner_id = (select auth.uid())
|
|
and g.hidden_at is null
|
|
and g.merged_into_id is null
|
|
)
|
|
);
|
|
|
|
create policy game_reports_update_seen on public.game_reports
|
|
for update to authenticated
|
|
using (owner_id = (select auth.uid()) and status <> 'open')
|
|
with check (owner_id = (select auth.uid()) and status <> 'open');
|
|
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- 3. Tageslimit (Krit. 7, Klärung 27, N12)
|
|
-- Setzt READ COMMITTED voraus (Standard bei PostgREST): Die Zählung nach der Sperre sieht die
|
|
-- inzwischen bestätigten Meldungen der anderen Transaktion. Die Zahl 10 steht nur hier und in
|
|
-- report_missing_game (Vorprüfung); eine Änderung braucht eine neue Migration.
|
|
-- security invoker: Der Nutzer sieht per RLS alle eigenen Meldungen; Studio (postgres) umgeht RLS.
|
|
-- ---------------------------------------------------------------------------------------------
|
|
create function public.game_reports_daily_limit()
|
|
returns trigger
|
|
language plpgsql
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0));
|
|
if (select count(*) from public.game_reports r
|
|
where r.owner_id = new.owner_id and r.origin = 'user'
|
|
and public.report_day(r.created_at) = public.report_day(now())) >= 10 then
|
|
raise exception 'report_daily_limit' using errcode = 'P0001';
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
-- Import-Meldungen (SAM-08) werden weder gezählt noch begrenzt (N12).
|
|
create trigger game_reports_daily_limit before insert on public.game_reports
|
|
for each row when (new.origin = 'user') execute function public.game_reports_daily_limit();
|
|
|
|
revoke execute on function public.game_reports_daily_limit() from public, anon, authenticated, service_role;
|
|
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- 4. RPC find_similar_catalog_games (Krit. 2, Klärung 15; Spec 3.3 und 4.3)
|
|
-- security invoker: es gelten die Policies des Aufrufers; gelesen wird über catalog_games (nur
|
|
-- sichtbare Katalogspiele). Kandidatentitel: Haupttitel (games.title_search) und alle alternativen
|
|
-- Titel (game_alt_titles.title_search). Regel A: gleicher Schlüssel, jedes Jahr. Regel B:
|
|
-- Trigramm-Ähnlichkeit >= 0.8 und Jahr +-1 (fehlt ein Jahr, ohne Jahresbedingung, Spec-F2).
|
|
-- extensions.similarity ausdrücklich, nicht der Operator % (der nutzt pg_trgm.similarity_threshold).
|
|
-- ---------------------------------------------------------------------------------------------
|
|
create function public.find_similar_catalog_games(p_title text, p_year integer default null)
|
|
returns table (
|
|
game_id uuid,
|
|
title text,
|
|
matched_title text,
|
|
year smallint,
|
|
publishers text[],
|
|
match_kind text,
|
|
similarity real
|
|
)
|
|
language sql
|
|
stable
|
|
set search_path = ''
|
|
as $$
|
|
with q as (
|
|
-- Jahr außerhalb 1800-2100 gilt wie null (kein Überlauf bei integer-Arithmetik, kein 22003)
|
|
select public.normalize_game_title(btrim(p_title)) as nk,
|
|
case when p_year between 1800 and 2100 then p_year end as y
|
|
where p_title is not null
|
|
and char_length(btrim(p_title)) between 1 and 200
|
|
),
|
|
cand as (
|
|
select c.id as game_id, c.title as game_title, c.year as game_year,
|
|
k.cand_title, k.cand_key, k.ord
|
|
from public.catalog_games c
|
|
join public.games g on g.id = c.id
|
|
cross join lateral (
|
|
select g.title as cand_title,
|
|
coalesce(nullif(public.strip_leading_article(g.title_search), ''), g.title_search, lower(btrim(g.title))) as cand_key,
|
|
0 as ord
|
|
union all
|
|
select a.title,
|
|
coalesce(nullif(public.strip_leading_article(a.title_search), ''), a.title_search),
|
|
1
|
|
from public.game_alt_titles a
|
|
where a.game_id = g.id
|
|
) k
|
|
),
|
|
scored as (
|
|
select cd.*, q.y, (cd.cand_key = q.nk) as is_same,
|
|
extensions.similarity(cd.cand_key, q.nk) as sim
|
|
from cand cd cross join q
|
|
where q.nk <> ''
|
|
),
|
|
hit as (
|
|
select distinct on (s.game_id)
|
|
s.game_id, s.game_title, s.game_year, s.cand_title, s.is_same, s.sim
|
|
from scored s
|
|
where s.is_same
|
|
or (s.sim >= 0.8
|
|
and (s.y is null or s.game_year is null or abs(s.game_year - s.y) <= 1))
|
|
order by s.game_id, s.is_same desc, s.sim desc, s.ord, s.cand_title
|
|
)
|
|
select h.game_id,
|
|
h.game_title,
|
|
h.cand_title,
|
|
h.game_year,
|
|
coalesce((select array_agg(p.name order by p.name_search, p.name)
|
|
from public.game_publishers gp
|
|
join public.publishers p on p.id = gp.publisher_id
|
|
where gp.game_id = h.game_id), '{}'::text[]),
|
|
case when h.is_same then 'same_title' else 'similar_title' end,
|
|
case when h.is_same then 1.0::real else h.sim end
|
|
from hit h
|
|
order by h.is_same desc, h.sim desc, h.game_title, h.game_id
|
|
limit 5
|
|
$$;
|
|
|
|
revoke execute on function public.find_similar_catalog_games(text, integer) from public, anon, authenticated, service_role;
|
|
grant execute on function public.find_similar_catalog_games(text, integer) to authenticated;
|
|
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- 5. RPC report_missing_game (Krit. 1, 2, 3, 7, 8; Spec 4.2)
|
|
-- security invoker: RLS, Spaltenrechte und Trigger gelten weiter. Eine Transaktion: jeder Fehler
|
|
-- rollt alles zurück (kein verwaistes vorläufiges Spiel). p_year ist integer und wird selbst
|
|
-- geprüft (smallint würde bei großen Werten schon beim Aufruf mit 22003 scheitern).
|
|
-- ---------------------------------------------------------------------------------------------
|
|
create function public.report_missing_game(
|
|
p_title text,
|
|
p_publisher_name text default null,
|
|
p_year integer default null,
|
|
p_link text default null,
|
|
p_list text default null,
|
|
p_confirmed_distinct boolean default false
|
|
)
|
|
returns jsonb
|
|
language plpgsql
|
|
set search_path = ''
|
|
as $$
|
|
declare
|
|
v_uid uuid := auth.uid();
|
|
v_title text;
|
|
v_publisher text;
|
|
v_link text;
|
|
v_game_id uuid;
|
|
v_report_id uuid;
|
|
v_item_id uuid;
|
|
v_constraint text;
|
|
begin
|
|
if v_uid is null then
|
|
raise exception 'not_authenticated' using errcode = 'P0001';
|
|
end if;
|
|
|
|
v_title := nullif(btrim(p_title, E' \t\r\n'), '');
|
|
v_publisher := nullif(btrim(p_publisher_name, E' \t\r\n'), '');
|
|
v_link := nullif(btrim(p_link, E' \t\r\n'), '');
|
|
|
|
if v_title is null then
|
|
raise exception 'report_title_required' using errcode = 'P0001';
|
|
end if;
|
|
if char_length(v_title) > 200 then
|
|
raise exception 'report_title_too_long' using errcode = 'P0001';
|
|
end if;
|
|
if char_length(v_publisher) > 200 then
|
|
raise exception 'report_publisher_too_long' using errcode = 'P0001';
|
|
end if;
|
|
if p_year is not null and p_year not between 1800 and 2100 then
|
|
raise exception 'report_year_invalid' using errcode = 'P0001';
|
|
end if;
|
|
if v_link is not null and (char_length(v_link) > 500 or v_link !~ '^https?://') then
|
|
raise exception 'report_link_invalid' using errcode = 'P0001';
|
|
end if;
|
|
if p_list is not null and p_list not in ('owned', 'wishlist', 'shopping') then
|
|
raise exception 'report_list_invalid' using errcode = 'P0001';
|
|
end if;
|
|
|
|
-- Vorprüfung vor der Ähnlichkeit: Der Nutzer soll nicht erst Kandidaten bestätigen und dann am
|
|
-- Limit scheitern. Maßgeblich bleibt der Trigger (Sperre).
|
|
if (select count(*) from public.game_reports r
|
|
where r.owner_id = v_uid and r.origin = 'user'
|
|
and public.report_day(r.created_at) = public.report_day(now())) >= 10 then
|
|
raise exception 'report_daily_limit' using errcode = 'P0001';
|
|
end if;
|
|
|
|
if not coalesce(p_confirmed_distinct, false)
|
|
and exists (select 1 from public.find_similar_catalog_games(v_title, p_year)) then
|
|
raise exception 'report_similar_exists' using errcode = 'P0001';
|
|
end if;
|
|
|
|
insert into public.games (owner_id, title, year, visibility)
|
|
values (v_uid, v_title, p_year, 'private')
|
|
returning id into v_game_id;
|
|
|
|
begin
|
|
insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id)
|
|
values (v_uid, v_title, v_publisher, p_year, v_link, v_game_id)
|
|
returning id into v_report_id;
|
|
exception when unique_violation then
|
|
get stacked diagnostics v_constraint = constraint_name;
|
|
if v_constraint = 'game_reports_provisional_game_id_key' then
|
|
raise exception 'report_provisional_in_use' using errcode = 'P0001';
|
|
end if;
|
|
raise;
|
|
end;
|
|
|
|
if p_list is not null then
|
|
insert into public.collection_items (owner_id, game_id, list)
|
|
values (v_uid, v_game_id, p_list)
|
|
returning id into v_item_id;
|
|
end if;
|
|
|
|
return jsonb_build_object(
|
|
'report_id', v_report_id,
|
|
'provisional_game_id', v_game_id,
|
|
'collection_item_id', v_item_id);
|
|
end;
|
|
$$;
|
|
|
|
revoke execute on function public.report_missing_game(text, text, integer, text, text, boolean)
|
|
from public, anon, authenticated, service_role;
|
|
grant execute on function public.report_missing_game(text, text, integer, text, text, boolean) to authenticated;
|
|
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- 6. RPC adopt_report_target (Krit. 5; Spec 4.4)
|
|
-- security definer, Eigentümer postgres. Begründung: Nutzer dürfen merged_into_id und hidden_at nicht
|
|
-- schreiben und repoint_game_references nicht ausführen; der Wechsel auf den Zieleintrag
|
|
-- ("abgelehnt mit Verweis") entscheidet aber der Nutzer selbst (Spec #2 Spec-F3).
|
|
-- Sicherheit: search_path leer (alles schema-qualifiziert); der Aufrufer wird nur über auth.uid()
|
|
-- bestimmt (nie über current_user); Meldung und vorläufiger Eintrag müssen dem Aufrufer gehören;
|
|
-- Ziel muss ein sichtbares Katalogspiel sein; hängen fremde Verweise am vorläufigen Eintrag, wird
|
|
-- abgebrochen. Innerhalb der Funktion ist current_user = postgres: Sie darf deshalb KEINE
|
|
-- admin_*-Funktion aufrufen (deren Rollenprüfung würde bestehen), weder jetzt noch später.
|
|
-- Sperrreihenfolge wie admin_resolve_report: erst die Meldung, dann beide Spiele nach id.
|
|
-- result_seen_at bleibt unverändert, damit der Hinweis am Zieleintrag erscheint, bis "Verstanden".
|
|
-- ---------------------------------------------------------------------------------------------
|
|
create function public.adopt_report_target(p_report uuid)
|
|
returns jsonb
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
declare
|
|
v_uid uuid := auth.uid();
|
|
rep public.game_reports;
|
|
prov public.games;
|
|
result jsonb;
|
|
begin
|
|
if v_uid is null then
|
|
raise exception 'not_authenticated' using errcode = 'P0001';
|
|
end if;
|
|
|
|
-- Meldung des Aufrufers sperren; fremde und unbekannte Meldungen liefern denselben Fehler.
|
|
select * into rep from public.game_reports r
|
|
where r.id = p_report and r.owner_id = v_uid
|
|
for update;
|
|
if not found then
|
|
raise exception 'report_not_found' using errcode = 'P0001';
|
|
end if;
|
|
if rep.status <> 'rejected' or rep.target_game_id is null then
|
|
raise exception 'report_not_adoptable' using errcode = 'P0001';
|
|
end if;
|
|
if rep.provisional_game_id is null then
|
|
raise exception 'report_no_provisional' using errcode = 'P0001';
|
|
end if;
|
|
|
|
perform 1 from public.games g
|
|
where g.id in (rep.provisional_game_id, rep.target_game_id)
|
|
order by g.id for update;
|
|
|
|
select * into prov from public.games g where g.id = rep.provisional_game_id;
|
|
if not found or prov.visibility <> 'private' or prov.owner_id is distinct from v_uid then
|
|
raise exception 'report_not_found' using errcode = 'P0001';
|
|
end if;
|
|
if prov.merged_into_id is not null then
|
|
raise exception 'report_already_adopted' using errcode = 'P0001';
|
|
end if;
|
|
if not exists (
|
|
select 1 from public.games g
|
|
where g.id = rep.target_game_id and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null
|
|
) then
|
|
raise exception 'report_target_not_listed' using errcode = 'P0001';
|
|
end if;
|
|
|
|
-- Verweise mit fremdem oder fehlendem Eigentümer (null zählt als fremd) brechen ab.
|
|
if exists (select 1 from public.collection_items x where x.game_id = prov.id and x.owner_id is distinct from v_uid)
|
|
or exists (select 1 from public.favorite_games x where x.game_id = prov.id and x.profile_id is distinct from v_uid)
|
|
or exists (select 1 from public.plays x where x.game_id = prov.id and x.owner_id is distinct from v_uid)
|
|
or exists (select 1 from public.score_sheets x where x.game_id = prov.id and x.owner_id is distinct from v_uid) then
|
|
raise exception 'report_foreign_references' using errcode = 'P0001';
|
|
end if;
|
|
|
|
-- privat -> Katalog: nichts wird kopiert; gleichnamige Varianten abgebildet, sonst null (RB1, N15)
|
|
result := public.repoint_game_references(prov.id, rep.target_game_id);
|
|
|
|
-- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement.
|
|
update public.games g
|
|
set merged_into_id = rep.target_game_id, hidden_at = coalesce(g.hidden_at, now())
|
|
where g.id = prov.id;
|
|
|
|
update public.game_reports r
|
|
set editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0)
|
|
where r.id = rep.id;
|
|
|
|
return result;
|
|
end;
|
|
$$;
|
|
|
|
revoke execute on function public.adopt_report_target(uuid) from public, anon, authenticated, service_role;
|
|
grant execute on function public.adopt_report_target(uuid) to authenticated;
|