-- 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 -- admin_resolve_report per create or replace: rejected nur ohne Ziel (Entscheidung Chris, 2026-11-04) -- game_reports_rejected_no_target Check: abgelehnte Meldungen haben nie ein Ziel -- Rechte-Regel (Spec #2 3.2): jede Funktion zuerst allen entziehen, danach nur die Grants aus Spec 4.1. -- editions_cleared stammt aus 2c. -- --------------------------------------------------------------------------------------------- -- 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. Entscheidung Chris (2026-11-04), ändert Spec-F3 aus Spec #2: Es gibt kein "abgelehnt mit Verweis" -- mehr. Wer auf einen vorhandenen Eintrag verweisen will, setzt "merged" (hängt direkt um, ohne -- Nutzeraktion). "rejected" gibt es nur ohne Ziel. Rumpf von admin_resolve_report unverändert aus 2c, -- ergänzt nur um die Prüfung report_reject_has_target (und den Kommentar zu "rejected"). -- --------------------------------------------------------------------------------------------- create or replace function public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null) returns jsonb language plpgsql security invoker set search_path = '' as $$ declare rep public.game_reports; prov public.games; result jsonb := '{}'::jsonb; repointed boolean := false; begin if current_user not in ('postgres', 'supabase_admin') then raise exception 'admin_only' using errcode = 'P0001'; end if; select * into rep from public.game_reports r where r.id = p_report for update; if not found then raise exception 'report_not_found' using errcode = 'P0001'; end if; if rep.status <> 'open' then raise exception 'report_not_open' using errcode = 'P0001'; end if; if p_status is null or p_status not in ('approved', 'merged', 'rejected') then raise exception 'report_status_invalid' using errcode = 'P0001'; end if; if p_status = 'rejected' and p_target is not null then raise exception 'report_reject_has_target' using errcode = 'P0001'; end if; if p_status in ('approved', 'merged') and p_target is null then raise exception 'report_target_required' using errcode = 'P0001'; end if; -- Beteiligte Spiele in fester Reihenfolge sperren (wie admin_merge_games). perform 1 from public.games g where g.id in (p_target, rep.provisional_game_id) order by g.id for update; if p_target is not null and not exists ( select 1 from public.games g where g.id = p_target 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; if p_status in ('approved', 'merged') and rep.provisional_game_id is not null then select * into prov from public.games g where g.id = rep.provisional_game_id; -- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag des Meldenden darf umgehängt -- werden. Ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert, und ein -- fremdes privates Spiel (Datenfehler im Studio) würde Daten eines anderen Nutzers umhängen. -- "not found" deckt eine fehlende Zeile ab (null <> 'private' wäre sonst nicht wahr). if not found or prov.visibility <> 'private' or prov.owner_id is distinct from rep.owner_id then raise exception 'report_provisional_not_private' using errcode = 'P0001'; end if; -- privat -> Katalog: nichts wird kopiert (RB1, Klärung N15) result := public.repoint_game_references(prov.id, p_target); repointed := true; -- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement. update public.games g set merged_into_id = p_target, hidden_at = coalesce(g.hidden_at, now()) where g.id = prov.id; end if; -- Bei "rejected" (nur ohne Ziel) bleibt der vorläufige Eintrag unverändert und privat. update public.game_reports r set status = p_status, target_game_id = p_target, resolved_at = now(), editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0) where r.id = rep.id; return result || jsonb_build_object( 'report_id', rep.id, 'status', p_status, 'target_id', p_target, 'provisional_game_id', rep.provisional_game_id, 'repointed', repointed ); end; $$; -- create or replace behält die Rechte; der Revoke wird trotzdem ausdrücklich wiederholt (Rechte-Regel). revoke execute on function public.admin_resolve_report(uuid, text, uuid) from public, anon, authenticated, service_role; -- Absicherung in der Tabelle (Studio-Handgriff): abgelehnte Meldungen haben nie ein Ziel. alter table public.game_reports add constraint game_reports_rejected_no_target check (status <> 'rejected' or target_game_id is null);