Shuffle/supabase/migrations/20261104100000_game_reports_user.sql
cschlaefke 3af18073a6
All checks were successful
CI / flutter (pull_request) Successful in 1m35s
CI / web-build (pull_request) Successful in 1m5s
CI / supabase (pull_request) Successful in 10s
CI / supabase-tests (pull_request) Successful in 43s
CI / e2e (pull_request) Successful in 3m21s
feat(db): kein 'abgelehnt mit Verweis' mehr, adopt_report_target entfällt (Entscheidung Chris, Refs #13)
admin_resolve_report weist rejected mit Ziel ab (report_reject_has_target),
Check game_reports_rejected_no_target, Tests und Docs angepasst.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:53:37 +01:00

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