All checks were successful
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>
400 lines
18 KiB
PL/PgSQL
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);
|