Shuffle/supabase/migrations/20261104100000_game_reports_user.sql
cschlaefke 154e5e4bb5 fix(db): Jahr-Grenzen in find_similar_catalog_games, Tests für Tagesgrenze und Trimmen (Refs #13)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:44:02 +01:00

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;