docs(catalog): Spec für KAT-04 Fehlendes Spiel melden (Issue #13) #58

Merged
cschlaefke merged 4 commits from docs/13-kat04-spec into main 2026-11-04 00:29:51 +00:00
Showing only changes of commit ece8e3eef2 - Show all commits

View file

@ -98,7 +98,8 @@ Falls #3 die Erweiterung schon anlegt, ist die Zeile ein No-op. Kein `unaccent`
|---|---|
| `unique (provisional_game_id) where provisional_game_id is not null` | 1:1 Meldung ↔ vorläufiger Eintrag (E1). Falls 2c ihn schon anlegt: entfällt. |
| `(target_game_id) where target_game_id is not null` | Hinweis am Eintrag: „gibt es eine Meldung von mir mit diesem Ziel?“ (5.4) |
| GIN `(extensions.gin_trgm_ops)` auf `public.normalize_game_title(title)` in `games`, `where visibility = 'catalog' and hidden_at is null and merged_into_id is null` | Ähnlichkeitssuche (4.3). Bei ~200–2000 Katalogspielen nicht zwingend, aber billig; KAT-01 kann ihn mitnutzen. |
**Kein Trigramm-Index:** Ein GIN-Index mit `gin_trgm_ops` würde nur über den Operator `%` genutzt, nicht über `similarity(...) >= 0.8` (3.3). Bei ~200–2000 Katalogspielen ist ein sequenzieller Durchlauf schnell genug (einfach vor clever). Braucht KAT-01 einen Trigramm-Index für die Suche, legt #3 ihn an.
**Rechte auf `game_reports`** (E3)
@ -108,6 +109,8 @@ grant insert (owner_id, title, publisher_name, year, link, provisional_game_id)
grant update (result_seen_at) on public.game_reports to authenticated;
```
**Bewusste Änderung gegenüber Spec #2 § 3.4:** Dort haben Nutzer kein Update-Recht auf `game_reports`, und `08_game_reports.test.sql` prüft, dass nur `postgres` Meldungen ändert. 13a erlaubt Nutzern genau eine Spalte (`result_seen_at`) an **eigenen**, bereits bearbeiteten Meldungen. Das bleibt mit KAT-05 Krit. 9 vereinbar (verboten ist das Ändern **fremder** Meldungen); B2 passt den Test aus `08` an.
`select` bleibt wie in 2c. **Nicht** schreibbar für Nutzer: `id`, `origin`, `status`, `bgg_id`, `target_game_id`, `resolved_at`, `created_at`, `updated_at` (Fehler 42501). Damit setzt die Datenbank `origin = 'user'`, `status = 'open'` (Krit. 8) und `created_at = now()` (Tageslimit) selbst. `delete` bekommt der Nutzer nicht.
**Merkpunkt für den Client (aus #2 PR 2a):** Clients schreiben `games` und `game_reports` nie mit `id`, `created_at` oder `updated_at`, und ein `toJson()` eines Modells geht nie ungefiltert an `insert`/`update`. In #13 schreibt die App ohnehin nur über die RPCs (4.2, 4.4) und das eine Update von `result_seen_at` (4.5) mit einer ausdrücklichen Spaltenliste.
@ -124,7 +127,8 @@ grant update (result_seen_at) on public.game_reports to authenticated;
- `before insert on public.game_reports for each row`, nur wenn `new.origin = 'user'`.
- Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. Ohne Sperre könnten zwei parallele Meldungen beide die Zählung „9“ sehen.
- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `created_at` am **selben Kalendertag in Europe/Berlin** (Spec-F4): `(created_at at time zone 'Europe/Berlin')::date = (now() at time zone 'Europe/Berlin')::date`. Bei ≥ 10 Fehler `report_daily_limit` (P0001).
- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `created_at` am **selben Kalendertag in Europe/Berlin** (Spec-F4): `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001).
- Hilfsfunktion `public.report_day(p_at timestamptz) returns date` = `(p_at at time zone 'Europe/Berlin')::date`, `language sql`, `immutable`, `search_path = ''`, `execute` nur für den Eigentümer. Sie ist eigens herausgezogen, damit pgTAP die Tagesgrenze mit festen Zeitstempeln prüfen kann (`now()` lässt sich im Test nicht steuern).
- Import-Meldungen (`origin = 'import'`, SAM-08) werden weder gezählt noch geprüft (N12).
- Funktion `security invoker` mit `search_path = ''`. Der Nutzer sieht per RLS alle eigenen Meldungen, mehr braucht die Zählung nicht; Studio (`postgres`) umgeht RLS ohnehin. `execute` wie oben entzogen (Trigger-Funktionen brauchen kein `execute`-Recht des Aufrufers).
- Die Zahl 10 steht als Konstante in der Funktion (kein Konfigurationswert); eine Änderung ist eine neue Migration.
@ -135,13 +139,14 @@ grant update (result_seen_at) on public.game_reports to authenticated;
**Funktion `public.normalize_game_title(p_title text) returns text`**, `language sql`, `immutable`, `strict`, `parallel safe`, `search_path = ''`. Schritte in dieser Reihenfolge:
1. `lower()`; `ß` → `ss`.
2. Alles, was kein Buchstabe und keine Ziffer ist (Satz- und Sonderzeichen, Bindestriche, Doppelpunkte, Apostrophe, Emojis), wird zu einem Leerzeichen. Umlaute und andere Buchstaben mit Akzent **bleiben** (Spec-F3). Umsetzung mit `regexp_replace(…, '[^[:alnum:]]+', ' ', 'g')`; backend-dev prüft per Test, dass `[:alnum:]` in der Datenbank-Locale Umlaute als Buchstaben erkennt (sonst explizite Zeichenklasse `[^a-z0-9äöüàáâèéêìíîòóôùúûñçåæøœ]`).
3. **Führende Artikel** entfernen, einmal, nur am Anfang (Spec-F3): Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`.
4. Mehrfache Leerzeichen zu einem, `trim`.
5. Ist das Ergebnis leer (Titel bestand nur aus einem Artikel oder Sonderzeichen), wird der Wert aus Schritt 2 (ohne Artikelentfernung) verwendet.
1. `lower()`; `ß` → `ss`, `æ` → `ae`, `œ` → `oe`.
2. **Akzente falten** (Spec-F3) mit einer festen, handgeschriebenen Zuordnung über `translate()` (z. B. `äàáâãå` → `a`, `ëèéê` → `e`, `ïìíî` → `i`, `öòóôõø` → `o`, `üùúû` → `u`, `ñ` → `n`, `ç` → `c`, `ÿý` → `y`). `translate()` ist `immutable`; `unaccent` wird nicht verwendet (nur `stable`).
3. Alles, was danach kein Zeichen aus `a–z` oder `0–9` ist (Satz- und Sonderzeichen, Bindestriche, Doppelpunkte, Apostrophe, Emojis, übrige Schriftzeichen), wird zu einem Leerzeichen: `regexp_replace(…, '[^a-z0-9]+', ' ', 'g')`. Die explizite Zeichenklasse hängt nicht von der Datenbank-Locale ab.
4. **Führende Artikel** entfernen, einmal, nur am Anfang (Spec-F3): Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`.
5. Mehrfache Leerzeichen zu einem, `trim`.
6. Ist das Ergebnis leer, wird der Wert aus Schritt 3 (ohne Artikelentfernung) verwendet; ist auch der leer (Titel nur aus Sonderzeichen oder nicht-lateinischen Schriftzeichen), `lower(btrim(p_title))`.
Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von catan`; „CATAN!“ → `catan`; „7 Wonders: Duel“ → `7 wonders duel`; „The Crew“ → `crew`; „Café International“ → `café international`; „Das“ → `das`.
Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von catan`; „CATAN!“ → `catan`; „7 Wonders: Duel“ → `7 wonders duel`; „The Crew“ → `crew`; „Café International“ → `cafe international`; „Mölkky“ und „Molkky“ → `molkky`; „Das“ → `das`.
**Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g`:
@ -159,7 +164,7 @@ Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von ca
| Objekt | Art | `execute`/Recht | Zweck |
|---|---|---|---|
| `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` (harmlos, rein rechnend; nötig für Index und Abfragen als Aufrufer) | Normalisierung (3.3) |
| `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` (harmlos, rein rechnend; nötig für Abfragen als Aufrufer, später auch für die Suche in #3) | Normalisierung (3.3) |
| `find_similar_catalog_games(p_title text, p_year smallint)` | RPC, security invoker | `authenticated` | Ähnliche Katalogspiele anzeigen (Krit. 2) |
| `report_missing_game(...)` | RPC, security invoker | `authenticated` | Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8) |
| `adopt_report_target(p_report uuid)` | RPC, **security definer** (Eigentümer `postgres`) | `authenticated` | Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (Krit. 5) |
@ -269,7 +274,7 @@ Kein RPC nötig; PostgREST-Abfragen als angemeldeter Nutzer:
### 4.7 Doku
- `docs/datenmodell-r1.md`: `game_reports.result_seen_at`, Indizes, Spaltenrechte, Policies, Trigger `game_reports_daily_limit`, die vier Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes.
- `docs/datenmodell-r1.md`: `game_reports.result_seen_at`, Indizes, Spaltenrechte, Policies, Trigger `game_reports_daily_limit`, die Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes.
- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (aus 2c) ergänzen: Nutzer-Meldungen haben immer einen vorläufigen Eintrag; „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (`adopt_report_target`), der Admin hängt dabei nichts um; vor `approved` die Edition berücksichtigen (Runbook aus 2c); Meldungen nie von Hand auf einen anderen Status setzen (nur über `admin_resolve_report`), weil die App den Hinweis aus Status und `merged_into_id` ableitet.
- `supabase/README.md`: neue RPCs und Rechte.
@ -388,12 +393,12 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We
| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task |
|---|---|---|---|---|---|
| Krit. 1 | `GameReportDraft`: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link-Format; Fehlercode-Mapping | Formular: Feldfehler am Feld, Bestätigung nach Erfolg, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → absenden → Bestätigung | `report_missing_game` legt Meldung (`status = open`, `origin = user`) und vorläufiges Spiel (`private`, `owner_id = A`, nur Titel/Jahr) an; Trimmen; alle Validierungsfehler (`report_title_required` … `report_list_invalid`); `anon` → 42501 | B1, B2, F2, T1, T2 |
| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` beim Senden → zurück zu Kandidaten | Kandidatenliste, Bestätigungs-Checkbox aktiviert Button, Tastatur im Web, Semantics | Playwright: Meldung zu „Catan“ zeigt vorhandenes „CATAN“ | `normalize_game_title`: Beispiele aus 3.3 (Artikel de/en, Sonderzeichen, Umlaute bleiben, `ß`, nur Artikel); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr trifft; Trigramm ≥ 0,8 bei Jahr ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; Jahr fehlt → Regel nach Spec-F2; ausgeblendete, zusammengeführte und private Spiele (auch eigene) werden nicht geliefert; höchstens 5, Sortierung; `report_missing_game` ohne Bestätigung bei Treffer → `report_similar_exists`, mit Bestätigung gespeichert | B1, B2, F2, U1, T1, T2 |
| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` beim Senden → zurück zu Kandidaten | Kandidatenliste, Bestätigungs-Checkbox aktiviert Button, Tastatur im Web, Semantics | Playwright: Meldung zu „Catan“ zeigt vorhandenes „CATAN“ | `normalize_game_title`: Beispiele aus 3.3 (Artikel de/en, Sonderzeichen, Akzente und Umlaute gefaltet, `ß`, nur Artikel, nur nicht-lateinische Zeichen); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr trifft; Trigramm ≥ 0,8 bei Jahr ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; Jahr fehlt → Regel nach Spec-F2; ausgeblendete, zusammengeführte und private Spiele (auch eigene) werden nicht geliefert; höchstens 5, Sortierung; `report_missing_game` ohne Bestätigung bei Treffer → `report_similar_exists`, mit Bestätigung gespeichert | B1, B2, F2, U1, T1, T2 |
| Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel steht in der Wunschliste (sofern #6/#8 vorhanden) | `p_list` legt `collection_items` zum vorläufigen Spiel an; B sieht weder vorläufiges Spiel noch Listeneintrag noch Meldung von A; `anon` sieht nichts; Rollback: schlägt der Listeneintrag fehl, gibt es weder Spiel noch Meldung | B1, B2, F2, F3 |
| Krit. 4 | `outcomeOf`: `approved` mit/ohne Edition-Hinweis | `ReportOutcomeNotice` für `approved` (de/en), „Verstanden“ blendet aus | integration_test/Playwright: Meldung per SQL-Hilfsfunktion im Testsetup mit `admin_resolve_report(…, 'approved', …)` bearbeiten → Hinweis am Eintrag | nach `admin_resolve_report` (2c) ist der Sammlungseintrag von A am Zielspiel mit Liste/Notiz/Kaufdatum; vorläufige Variante → `variant_id` null, private Variante bleibt am vorläufigen Eintrag und ist für A lesbar (E7); `result_seen_at` setzbar | B2, F3, T1, T2 |
| Krit. 5 | `outcomeOf`: `merged`, `rejectedWithTarget`, `rejectedAdopted` | Hinweise und Aktion „wechseln“ mit Bestätigungsdialog | Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel | `adopt_report_target`: Erfolg hängt Sammlung und Partien von A um, setzt `merged_into_id`/`hidden_at`, Status bleibt `rejected`, `result_seen_at` gesetzt; Fehler `report_not_found` (fremde Meldung von B, unbekannte ID), `report_not_adoptable` (`open`, `approved`, `rejected` ohne Ziel), `report_already_adopted` (zweiter Aufruf), `report_target_not_listed` (Ziel inzwischen ausgeblendet), `report_foreign_references` (Fixture als `postgres`); `anon` → 42501 | B1, B2, F3, T1, T2 |
| Krit. 6 | `outcomeOf`: `rejected` ohne Ziel | dauerhafte Markierung „Abgelehnt“ bleibt nach „Verstanden“ | – | nach `admin_resolve_report(…, 'rejected')` bleibt das vorläufige Spiel unverändert privat und für A lesbar | B2, F3, T2 |
| Krit. 7 | Mapping `report_daily_limit` → `dailyLimit` | Limit-Hinweis, Eingaben bleiben erhalten | – | 10 Meldungen am selben Berliner Kalendertag ok, 11. → `report_daily_limit` und **kein** vorläufiges Spiel übrig (Rollback); Meldungen von gestern (Berliner Zeit, Fixture mit `created_at` als `postgres` gesetzt) zählen nicht; Grenzfall 23:30 UTC (= nächster Tag in Berlin im Winter); `origin = 'import'` als `postgres` eingefügt zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; direkter Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 |
| Krit. 7 | Mapping `report_daily_limit` → `dailyLimit` | Limit-Hinweis, Eingaben bleiben erhalten | – | 10 Meldungen am selben Berliner Kalendertag ok, 11. → `report_daily_limit` und **kein** vorläufiges Spiel übrig (Rollback); Meldungen von gestern (Fixture mit `created_at = now() - interval '1 day'` als `postgres` gesetzt) zählen nicht; Tagesgrenze über `report_day` mit festen Zeitstempeln (`2026-01-15 23:30 UTC` → 16.01., `2026-07-15 21:59 UTC` → 15.07., `22:00 UTC` → 16.07.); `origin = 'import'` als `postgres` eingefügt zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; direkter Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 |
| Krit. 8 | – | Status „offen“ in Liste und Detail | – | Nutzer kann `status`, `target_game_id`, `resolved_at` weder per Insert noch per Update setzen (42501); Update von `result_seen_at` bei `status = open` → 0 Zeilen; nach dem Insert ist der Status `open`; kein Trigger/Job ändert ihn | B2, T1 |
| Sicherheit Krit. 1, 3 | – | – | – | B liest A's Meldungen und vorläufige Spiele nicht; B kann A's Meldung nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit `owner_id = A` als B → Policy-Fehler; `provisional_game_id` auf ein fremdes, ein Katalog- oder ein ausgeblendetes Spiel → Policy-Fehler; Spaltenrechte per `has_column_privilege` | B2 |
| Sicherheit Krit. 2 | – | – | – | `anon`: kein `select`, `insert`, `update` auf `game_reports`, keine RPC ausführbar | B2 |
@ -402,7 +407,7 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We
**Nicht automatisiert:** echte Parallelität des Limits (die Sperre wird per Code-Review und einem dokumentierten manuellen Test mit zwei `psql`-Sitzungen geprüft); Bearbeitung echter Meldungen im Studio auf Staging (Chris).
**Bestehende Tests:** `08_game_reports.test.sql` aus 2c erwartet „kein Insert für `authenticated`“; 13a passt diesen Test auf die neuen Spaltenrechte an (B2). `00_schema.test.sql` bleibt bei 21 Tabellen.
**Bestehende Tests:** `08_game_reports.test.sql` aus 2c erwartet „kein Insert und kein Update für `authenticated`“ bzw. „nur `postgres` ändert Meldungen“; 13a passt beide Erwartungen auf die neuen Spaltenrechte an (Insert der erlaubten Spalten, Update nur `result_seen_at` an eigenen bearbeiteten Meldungen; alles andere weiter 42501) (B2). `00_schema.test.sql` bleibt bei 21 Tabellen.
## 8. Aufgaben
@ -410,8 +415,8 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B1 | backend-dev | Migration `<ts>_game_reports_user.sql` nach 3.2–4.5: `pg_trgm`, `normalize_game_title` (oder Wiederverwendung aus #3, 3.3), Spalte `result_seen_at` mit Check, Indizes, Spaltenrechte, Policies `game_reports_insert_own` und `game_reports_update_seen`, Trigger `game_reports_daily_limit` (Berliner Kalendertag, Sperre je Nutzer, nur `origin = 'user'`), RPCs `find_similar_catalog_games`, `report_missing_game`, `adopt_report_target`; Rechte nach 4.1 | #2 PR 2c gemergt |
| B2 | backend-dev | pgTAP `09_game_reports_user.test.sql` nach 7.1; `08_game_reports.test.sql` an die neuen Insert-Rechte anpassen | B1 |
| B1 | backend-dev | Migration `<ts>_game_reports_user.sql` nach 3.2–4.5: `pg_trgm`, `normalize_game_title` (oder Wiederverwendung aus #3, 3.3), Spalte `result_seen_at` mit Check, Indizes, Spaltenrechte, Policies `game_reports_insert_own` und `game_reports_update_seen`, Trigger `game_reports_daily_limit` mit Hilfsfunktion `report_day` (Berliner Kalendertag, Sperre je Nutzer, nur `origin = 'user'`), RPCs `find_similar_catalog_games`, `report_missing_game`, `adopt_report_target`; Rechte nach 4.1 | #2 PR 2c gemergt |
| B2 | backend-dev | pgTAP `09_game_reports_user.test.sql` nach 7.1; `08_game_reports.test.sql` an die neuen Insert- und Update-Rechte anpassen | B1 |
| B3 | backend-dev | Doku nach 4.7 (`datenmodell-r1.md`, `katalog-pflege.md` Abschnitt Meldungen, `supabase/README.md`) | B1 |
| T1 | test-engineer | Review der pgTAP-Abdeckung gegen 7.1; manueller Paralleltest des Limits (zwei Sitzungen) dokumentieren | B2 |
@ -446,7 +451,7 @@ F1 und U1 starten sofort nach Freigabe der Spec, parallel zu B1. 13b schließt #
| R1 | 2b/2c weichen bei der Umsetzung von den in Spec #2 beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen |
| R2 | `adopt_report_target` ist `security definer` und erreicht `repoint_game_references`, das RLS umgeht | Fehler würden fremde Daten berühren | Strenge Prüfungen (4.4) inkl. Prüfung auf fremde Verweise, `for update`, nur eigene private, nicht zusammengeführte Spiele; pgTAP-Negativfälle; Code-Review mit Fokus Sicherheit |
| R3 | Trigramm-Schwelle 0,8 ist bei kurzen Titeln streng oder bei Reihen („Catan“, „Catan: Seefahrer“) großzügig | Ähnliche werden nicht gezeigt bzw. zu viele | Klärung 15 ist verbindlich; Kandidatenschritt blockiert nicht, er verlangt nur eine Bestätigung; Erfahrungen nach R1 sammeln |
| R4 | `[:alnum:]` hängt von der Datenbank-Locale ab | Umlaute würden entfernt, Normalisierung falsch | Test mit Umlauten in pgTAP; Rückfall auf explizite Zeichenklasse (3.3) |
| R4 | `pg_trgm` erkennt Wortzeichen abhängig von der Datenbank-Locale | Ähnlichkeitswerte weichen zwischen lokal und Produktion ab | Normalisierung liefert nur `a–z`, `0–9` und Leerzeichen (3.3); damit ist die Trigrammbildung locale-unabhängig; pgTAP-Fälle mit Umlauten und Akzenten |
| R5 | Edition-Hinweis per Ableitung (E7) zeigt auch dann „Edition neu wählen“, wenn der Nutzer nie eine Edition gewählt hatte, aber eine private Variante existiert | Unnötiger Hinweis | Selten (vorläufige Einträge haben anfangs keine Varianten); wegklickbar; Alternative in Spec-F6 |
| R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11, die evtl. noch fehlen | Krit. 4–6 nur in „Meine Meldungen“ sichtbar | Spec-F8: Abhängigkeiten setzen oder Einbau dort nachziehen |
| R7 | Nutzer melden das gleiche Spiel mehrfach (eigene offene Meldungen werden nicht geprüft, Spec-F7) | Mehr Arbeit für Chris, doppelte vorläufige Einträge | Tageslimit; Chris lehnt Duplikate mit Verweis ab |
@ -460,7 +465,7 @@ Keine Frage blockiert die Umsetzung: Für jede gibt es eine umsetzbare Empfehlun
|---|---|---|---|
| Spec-F1 | **Krit. 1:** Sind Verlag und Erscheinungsjahr Pflicht oder optional? Der Text nennt nur den Titel als Pflicht. | **Optional**, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; eine Pflicht würde zu geratenen Werten führen. 2c hat beide Spalten nullable. |
| Spec-F2 | **Krit. 2:** Wie gilt die Trigramm-Regel, wenn das Jahr in der Meldung oder im Katalogeintrag fehlt? | Trigramm ≥ 0,8 **ohne Jahresbedingung**. | Lieber einmal mehr nachfragen als ein Duplikat; der Schritt verlangt nur eine Bestätigung. |
| Spec-F3 | **Krit. 2 „Artikel und Sonderzeichen ignoriert“:** Welche Artikel, wo, und zählen Umlaute/Akzente als Sonderzeichen? | Nur **führende** Artikel auf Deutsch und Englisch (Liste in 3.3); Umlaute und Akzente **bleiben** Buchstaben, `ß` = `ss`; alle anderen Zeichen werden ignoriert. | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“). Akzent-Faltung (`unaccent`) ist nicht `immutable` und damit für Index und gemeinsame Funktion ungeeignet; der Trigramm-Vergleich fängt „Molkky/Mölkky“ meist trotzdem. |
| Spec-F3 | **Krit. 2 „Artikel und Sonderzeichen ignoriert“:** Welche Artikel, wo, und zählen Umlaute/Akzente als Sonderzeichen? | Nur **führende** Artikel auf Deutsch und Englisch (Liste in 3.3); Umlaute und Akzente werden **gefaltet** (`ö` = `o`, `é` = `e`, `ß` = `ss`) über eine feste `translate()`-Zuordnung; alle anderen Zeichen werden ignoriert. | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“). Ohne Faltung würden „Mölkky“ und „Molkky“ **nicht** als ähnlich erkannt: Die Trigramm-Ähnlichkeit liegt bei etwa 0,4, weit unter 0,8. `unaccent` scheidet aus, weil es nicht `immutable` ist; `translate()` ist es und braucht keine Erweiterung. Nachteil: „ae“-Schreibweisen („Raeuber“) werden nicht auf „ä“ abgebildet. Alternative: Umlaute als eigene Buchstaben behalten (strenger, dann werden Schreibvarianten mit und ohne Umlaut nicht erkannt). |
| Spec-F4 | **Krit. 7 „heute“:** Kalendertag in welcher Zeitzone, oder gleitende 24 Stunden? | **Kalendertag in Europe/Berlin.** | Entspricht „heute“ im Kriterium und ist für Nutzer verständlich („morgen geht es wieder“). Eine Profil-Zeitzone gibt es nicht. |
| Spec-F5 | **Krit. 4–6:** Wie lange bleibt der Hinweis am Eintrag sichtbar? | **Bis der Nutzer „Verstanden“ wählt** (`result_seen_at`). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft; in „Meine Meldungen“ bleibt der Status immer sichtbar. | Ein dauerhafter Hinweis an einem längst ersetzten Eintrag stört; ein automatisches Ausblenden nach Zeit könnte übersehen werden. |
| Spec-F6 | **N15, Edition:** Woran erkennt die App, dass die Edition geleert wurde? | **Ableitung** (E7): vorläufiger Eintrag hat eine eigene Variante und der Eintrag am Ziel hat keine Edition. Alternative: Spalte `game_reports.edition_cleared`, gesetzt von `admin_resolve_report`/`adopt_report_target`; dafür müsste `repoint_game_references` (2b) zusätzlich zählen, wie viele **Verweise** geleert wurden, und 2c bzw. 13a die Funktionen anpassen. | Die Ableitung braucht keine Änderung an den bereits freigegebenen Teilen 2b/2c. Die Alternative ist genauer und lohnt sich, wenn 2b/2c ohnehin noch nicht gebaut sind; dann sollte die Änderung in Spec #2 nachgetragen werden, nicht in 13a per `create or replace`. |
@ -495,3 +500,5 @@ Keine Frage blockiert die Umsetzung: Für jede gibt es eine umsetzbare Empfehlun
### 2026-11-03 – Entwurf
Erste Fassung durch architect. Berücksichtigt den Hinweis aus der KAT-05-Spec (Issue-Kommentar vom 2026-11-03, Klärung N15) zur eingeschränkten Erhaltung der Edition bei Krit. 4.
Nachtrag am selben Tag: Normalisierung faltet Akzente per `translate()` (Spec-F3 neu begründet: ohne Faltung läge „Mölkky/Molkky“ bei etwa 0,4); Trigramm-Index gestrichen (wäre mit `similarity() >= 0.8` nicht nutzbar); Abweichung von Spec #2 § 3.4 (Update-Recht auf `result_seen_at`) ausdrücklich vermerkt und Testanpassung in B2 ergänzt; Hilfsfunktion `report_day` für testbare Tagesgrenze.