Files
fdbck/docs/plans/feedback-feature.md
mAi b914294769 README + design doc copy
- README.md: stack, run-locally, test/check/build, structure tree, data
  model summary, anti-abuse layers, scope notes, issue origin pointer.
- docs/plans/feedback-feature.md: copied verbatim from flexsiebels for
  self-containment (single source of truth in this repo from now on).
2026-05-05 11:38:11 +02:00

325 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Feedback Feature — Forms + Live-Chat unter flexsiebels.de
**Status**: design / awaiting m's go-no-go gate
**Issue**: m/flexsiebels.de#63
**Inventor**: knuth (shift-1, 2026-05-05)
**Trigger**: HL UPC-Schulungen (28.05. Fristen, 25.06. Kosten), Talks, Workshops, mAi-Demos
---
## 1. Wunsch (verbatim, m, PWA-voice 2026-05-05 09:52)
> *"Ich brauche eine Feedback-Webseite, die mir erlaubt, Formulare, aber auch so eine Live-Chat-Maske zur Verfügung zu stellen. Die Nutzer sollen sich einen Namen geben können, wenn sie wollen, aber ansonsten auch anonym bleiben. Im Wesentlichen quasi Microsoft Forms und Teams-Feedback in einem auf einer Webseite. Und wir machen die grundsätzlich nicht authentifiziert, nur eben mit langen Links — Security by Obscurity."*
---
## 2. Stack-Reality-Check (vor Design)
Issue + `.claude/CLAUDE.md` referenzieren noch Deno Fresh + Preact. **Falsch.** Live-Stack ist:
- **SvelteKit 2.15 + Svelte 5** (runes), Bun, Vite 6, `@sveltejs/adapter-node`
- **Supabase** mit getrennten Schemas: `flexsiebels.*` (App-Tabellen, helper `flex()`), `mbrian.*` (Graph, helper `mb()`), `public.*` (Infra)
- **Auth**: Cookie-JWT oder API-Key, Hook in `src/hooks.server.ts` setzt `locals.userId`. Anonyme Requests an `/api/*` werden vom **public-scope policy gate** (issue #59) per Default zu 401 — Ausnahmen: `/api/public/*`, `/api/share/*`, `/api/auth/*`, `/api/gotify-public/*`. Diese Liste werden wir um den Feedback-Pfad erweitern.
- **Migrations** in `website/migrations/` als nummerierte SQL-Dateien, **manuell** auf supa.flexsiebels.de via Supabase Studio (kein Auto-Runner).
- **Validation**: Zod-Schemas in `src/lib/server/schemas.ts`.
`.claude/CLAUDE.md` ist stale — separater Cleanup-Issue empfohlen, nicht Teil dieses Tickets.
---
## 3. Scope v1
### In
- Pro Feedback-Event eine **Instance**, erreichbar über langen URL-Slug `/f/<32-base62>`.
- Instance-Konfig kann **Form-Modus, Chat-Modus oder beides parallel** liefern (Datenmodell trägt beides, UI rendert was konfiguriert ist).
- **Form-Felder**: short_text, long_text, single_choice, multi_choice, scale, boolean. Definition als JSON in m's Admin-View (kein drag-drop-Builder in v1).
- **Live-Chat**: kurze Posts in Liste, optional displayed_name, **Polling** alle 3s (kein SSE/Realtime in v1).
- **Anti-Abuse**: Per-IP-Rate-Limit (in-memory bucket), Body-Length-Caps, Honeypot-Feld, Closing-Switch.
- **m-Admin** unter `/admin/feedback`: Liste aller Instances, Detail-View mit live-Chat + Submissions, Hide-Post-Button, Close-Button, **CSV/JSON-Export**.
- **Persistenz Teilnehmer**: LocalStorage trägt `display_name` + `client_session_id` (UUID), Wieder-Aufruf zeigt eigene Posts hervorgehoben + Name vorausgefüllt.
- **Closing**: Instance-Status `open` | `closed`. Closed → POST-Endpunkte 423, Read funktioniert weiter.
- **noindex** für `/f/*` (robots.txt + meta tag).
### Out (v1)
- Drag-Drop Form-Builder (JSON-Editor reicht — m ist technisch)
- Reaktionen (👍 etc.) auf Live-Posts
- Branding/Theming pro Instance
- Trusted-Tier Auth-Integration (Track D, separat)
- Multi-Page-Forms, Logik-Branching, File-Upload
- A/B-Testing, Cross-Instance-Stats
- Real-Time via Supabase Realtime (Polling reicht; Migration zu Realtime trivial wenn nötig)
- CAPTCHA / Turnstile (Honeypot + Rate-Limit + Kill-Switch reichen für v1; Eskalation später möglich)
---
## 4. URL-Schema
| Pfad | Zweck | Auth |
|---|---|---|
| `/f/<slug>` | Public participant page (form + chat) | none |
| `/admin/feedback` | m's overview list | required |
| `/admin/feedback/<id>` | Instance detail (live + submissions + moderate + export) | required |
| `/api/public/feedback/<slug>` | GET instance config (form schema, chat_enabled, status) | none |
| `/api/public/feedback/<slug>/submit` | POST form submission | none |
| `/api/public/feedback/<slug>/posts?since=<ts>` | GET chat posts since timestamp | none |
| `/api/public/feedback/<slug>/posts` | POST new chat post | none |
| `/api/admin/feedback` | POST create / GET list | required |
| `/api/admin/feedback/<id>` | PATCH / DELETE | required |
| `/api/admin/feedback/<id>/posts/<post_id>/hide` | POST toggle hidden | required |
| `/api/admin/feedback/<id>/export?format=csv\|json` | GET export | required |
**Slug-Entropy**: 32 chars base62 ≈ 190 bits. Brute-Force-resistent über HTTP. Optional via shlink (`/api/share`) verkürzbar wenn m verbal teilt.
**Conventions-Check**: passt zu `getCSSForRoute`-Pattern und `routes/api/<resource>` REST-Style.
---
## 5. Datenmodell (Schema `flexsiebels`)
```sql
-- One feedback "event" — form, chat, or both
CREATE TABLE flexsiebels.feedback_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE, -- 32-char base62
title TEXT NOT NULL, -- m-only label, e.g. "HL UPC 28.05."
description TEXT, -- shown to participants on landing
owner_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
form_definition JSONB, -- { questions: [...] } | NULL if no form
chat_enabled BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'open',
closed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (status IN ('open','closed')),
CHECK (form_definition IS NOT NULL OR chat_enabled = true)
);
CREATE INDEX feedback_instances_owner_idx
ON flexsiebels.feedback_instances(owner_user_id, created_at DESC);
CREATE INDEX feedback_instances_slug_idx
ON flexsiebels.feedback_instances(slug);
-- Form submissions (one row per submit)
CREATE TABLE flexsiebels.feedback_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES flexsiebels.feedback_instances(id) ON DELETE CASCADE,
display_name TEXT, -- nullable = anonymous
client_session_id TEXT NOT NULL, -- LocalStorage UUID
answers JSONB NOT NULL, -- { question_id: value }
client_ip INET, -- best-effort, abuse forensics
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX feedback_submissions_instance_idx
ON flexsiebels.feedback_submissions(instance_id, created_at DESC);
-- Live chat posts
CREATE TABLE flexsiebels.feedback_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES flexsiebels.feedback_instances(id) ON DELETE CASCADE,
display_name TEXT,
client_session_id TEXT NOT NULL,
body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
hidden BOOLEAN NOT NULL DEFAULT false, -- m soft-moderates
client_ip INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX feedback_posts_instance_idx
ON flexsiebels.feedback_posts(instance_id, created_at);
```
**Migration-File**: `website/migrations/20260505_create_feedback_tables.sql`.
Wie alle flexsiebels-Migrations: **manuell** als `supabase_admin` in Supabase Studio anwenden (siehe `20260504_move_to_flexsiebels_schema.sql` Header für die Begründung).
**RLS-Strategie**: Tabellen schreibt nur die App via `flex()` (service role, RLS bypass). Public-API-Handler validieren slug-basierten Zugriff selbst — keine RLS-Policies nötig (würde Anon-Zugang sowieso nicht durch Slug autorisieren). Konsistent mit dem Pattern der übrigen `flexsiebels.*`-Tabellen.
**Form-Definition JSON-Shape** (validiert per Zod):
```ts
type Question =
| { id: string; type: 'short_text'; label: string; required?: boolean; help?: string; placeholder?: string }
| { id: string; type: 'long_text'; label: string; required?: boolean; help?: string; placeholder?: string }
| { id: string; type: 'single_choice';label: string; required?: boolean; help?: string; options: string[] }
| { id: string; type: 'multi_choice'; label: string; required?: boolean; help?: string; options: string[] }
| { id: string; type: 'scale'; label: string; required?: boolean; help?: string; min: number; max: number; min_label?: string; max_label?: string }
| { id: string; type: 'boolean'; label: string; required?: boolean; help?: string };
type FormDefinition = {
intro?: string; // markdown allowed (sanitized via DOMPurify)
outro?: string; // shown after submit
questions: Question[];
};
```
---
## 6. Frontend-Skizze
### `/f/<slug>` — Participant Page (mobile-first)
Reihenfolge auf der Seite:
1. **Header**: instance.title + (optional) instance.description
2. **Optional**: "Dein Name (optional)" Input — leer = anonym, gespeichert in LocalStorage
3. **Wenn `chat_enabled`**: Live-Chat-Maske
- Posts-Liste, neueste oben oder unten? → **unten**, neueste unten (Teams-Stil, scrollt mit)
- Eigene Posts hervorgehoben (matched via `client_session_id`)
- Hidden Posts erscheinen für andere als "Beitrag entfernt", für eigenen Verfasser unverändert
- Eingabefeld + Send-Button
- Polling alle 3s mit `?since=<latest_ts>` (Long-Poll-light: nur Delta)
4. **Wenn `form_definition`**: Form rendern
- Sequentielle Felder, Submit-Button am Ende
- Bei `required` Validation, sonst frei
- Nach erfolgreichem Submit: Outro-Text + "Noch eine Antwort senden"-Link
Wenn `status='closed'`:
- Banner "Diese Feedback-Sitzung ist geschlossen."
- Form readonly oder versteckt
- Chat readonly (Posts sichtbar, Eingabe weg)
**SEO**: `<svelte:head><meta name="robots" content="noindex,nofollow"/></svelte:head>`. `/f/*` zusätzlich in `static/robots.txt` als `Disallow: /f/`.
### `/admin/feedback` — m's Admin
- Liste aller Instances: Title, Slug (mit Copy-Button), Mode (Form/Chat/Beides), Status, Counts (#submissions, #posts), Created
- "Neue Instance" → Formular: Title, Description, Form-Definition (JSON-Textarea mit Live-Validation), Chat-Enabled-Checkbox
### `/admin/feedback/<id>` — m's Detail
- Header: Title, Slug-Link, Status mit Close/Reopen-Button
- Tabs: **Live Chat** | **Form Submissions**
- Live-Chat-Tab: alle Posts inkl. hidden (markiert), pro Post Hide/Unhide-Button
- Submissions-Tab: Tabellen-View aller Form-Antworten + per-row Detail
- Export-Buttons: CSV (separates File für Submissions + Posts) + JSON (kompletter Dump)
- Edit-Button für Form-Definition (nur erlaubt solange keine Submissions vorhanden, sonst Warnung — Schema-Drift)
---
## 7. Anti-Abuse-Strategie
| Layer | Mechanismus | Default |
|---|---|---|
| **Body size** | Length-Caps in Zod-Schema + DB-Constraint | post ≤ 2000 chars, long_text ≤ 5000, single field labels ≤ 200 |
| **Per-IP rate** | In-memory token bucket pro Pfad | 30 posts / 5 min, 10 submits / 5 min, GET unlimitiert |
| **Honeypot** | CSS-hidden field "company" — wenn gefüllt: 200 OK aber kein Insert | always on |
| **Slug-Entropy** | 190 bits — Discovery via Brute-Force unrealistisch | enforced |
| **Closing-Switch** | m setzt `status='closed'` → POST returns 423 | manual |
| **Display-Name length** | ≤ 80 chars, no newlines | enforced |
| **noindex** | `<meta>` + robots.txt | always on |
In-Memory Rate-Limit lebt im SvelteKit-Node-Prozess (`Map<string, { count, resetAt }>`). Ausreichend für v1 — bei Scale-Out-Bedarf später Redis.
**Was wir bewusst NICHT machen v1**: Cloudflare Turnstile (zusätzliches Account-Setup, JS-Bundle), CAPTCHA, IP-Blocking-Tabelle. Wenn wir gespammt werden: Issue → Turnstile in 1 Iteration nachrüstbar.
---
## 8. Technische Entscheidungen + Trade-Offs
### Polling vs. Realtime
Polling alle 3s gewählt:
- + Trivial zu debuggen, funktioniert hinter jedem Reverse-Proxy, kein WebSocket-Stack
- + Adapter-node + Traefik-Setup unverändert
- Bei vielen aktiven Teilnehmern Last höher als Push
- → Migration zu Supabase Realtime ist Drop-In auf gleichem Datenmodell, deferrable bis "viele aktive" tatsächlich passiert
### Form-Builder UX
JSON-Editor in `/admin/feedback/[id]` mit Live-Preview-Pane:
- + Schnell zu bauen, m kann formulieren wie er will
- + Gut für Versionskontrolle / Wiederverwendung (m kann JSON teilen)
- Nicht-technische Owner ausgeschlossen (für v1 ist nur m owner — irrelevant)
- → Drag-Drop-Builder als separates Issue wenn Trusted-Tier kommt und HL-Kollegen ihre eigenen Forms bauen sollen
### Mode: Form OR Chat OR Both
Schema trägt beides parallel (`form_definition` nullable, `chat_enabled` bool, CHECK-Constraint dass mindestens eines an ist):
- + Maximum Flexibilität ohne Schema-Aufwand
- + UI rendert was konfiguriert ist — keine Modus-Verzweigung
- Wenn m beides nicht will, brauchen wir trotzdem keine separate Schema-Variante
m's Frage 4 ("Kombi-Modus wirklich gewünscht?") → **Ja, Schema unterstützt es zero-cost**. Falls m das verbieten will, einfach im Admin-UI nicht beides gleichzeitig erlauben — schnelles Update, keine Migration.
### Persistenz für Teilnehmer
LocalStorage:
- `feedback:display_name` (global, alle Instances)
- `feedback:session:<slug>` = `{ session_id, submitted_at?, posts: [...] }`
Server kennt nur `client_session_id` — kein Cookie, kein Tracking-Cookie-Banner-Theater. Browser-isolated.
### IP-Speicherung
`client_ip` + `user_agent` werden gespeichert für Abuse-Forensik. **Empfehlung**: nach 30 Tagen automatisch nullen (separate Cron-Migration, später). v1 schreibt einfach.
### Eindeutiger Owner
`owner_user_id` → m. Trusted-Tier-Owner kommt mit Track D, nicht jetzt. Admin-Routes prüfen bewusst nicht "is_owner == auth.uid" sondern nur `requireAuth` — m ist alleiniger Auth-Nutzer in v1. Wenn Trusted-Tier kommt: Filter ergänzen.
---
## 9. Open Questions — Vorgeschlagene Defaults für m's Gate
Jede Frage mit empfohlenem Default. **m: 👍 alle, oder einzelne überschreiben.**
| # | Frage | Mein Default | Rationale |
|---|---|---|---|
| 1 | Form-Builder-UX: Drag-Drop oder JSON? | **JSON-Editor mit Live-Preview** | m ist technisch, JSON ist schnell zu bauen, Builder kommt mit Trusted-Tier |
| 2 | Live-Chat-Posts editierbar/löschbar? | **m moderiert via Hide-Toggle (soft-delete). Teilnehmer kann NICHT eigenes löschen.** | Anonyme Identität → kein verlässliches "ist meiner". Cleaner als Half-Implementation |
| 3 | Reaktionen (👍 etc.) auf Posts? | **Nein in v1.** | Schema-Erweiterung trivial wenn gewünscht; nicht im Critical Path |
| 4 | Kombi-Modus Form+Chat parallel? | **Ja, Schema trägt beides parallel.** UI rendert was konfiguriert ist. | Zero zusätzlicher Aufwand vs. mode-enum, max Flexibilität |
| 5 | Persistenz Teilnehmer (Name + Chronologie)? | **Ja, LocalStorage.** Name global pre-filled, eigene Posts hervorgehoben. | UX-Selbstverständlichkeit, kein Cookie-Banner |
| 6 | Closing-Mechanik? | **Ja, status='closed' sperrt POST (423), GET bleibt open für Read-Only.** | Standard-Pattern, m braucht Kill-Switch |
Zusätzliche Fragen, die ich auch noch m stellen würde:
| # | Frage | Mein Default | Rationale |
|---|---|---|---|
| 7 | Sichtbarkeit Live-Chat: alle sehen alles, oder Posts erst nach m's Approval? | **Sofort sichtbar** für alle Teilnehmer. m kann hidden setzen. | Teams-Stil, Q&A-Style; "Approval-First" ist eigener Modus → später |
| 8 | Anonyme Posts erlauben (display_name ganz weg) oder zwingen "Anonym" als Default-Label? | **`display_name=NULL` → UI zeigt "anonym"** | Konsistent, kein Ghost-Cell |
| 9 | Session-Expiry für `client_session_id`? | **Lebt für die LocalStorage-Lifetime** (kein Server-Expiry) | Einfach, anonyme Posts brauchen kein Expiry |
| 10 | Notification an m wenn neue Submission/Post? | **Nein in v1.** Optional via `gotify` später nachrüstbar. | Out of scope, m schaut Admin-View während Event |
---
## 10. Implementation-Roadmap (für Coder-Shift, falls m grünlicht)
Tracer-Bullet-Order — jeder Schritt liefert eine durchdachte Inkrement-Demo:
1. **DB-Migration** + `feedback_instances` Helper im `flex()`-Stil
2. **Public-Scope Allowlist**: `/^\/api\/public\/feedback(\/|$)/` ergänzen in `public-scope.ts`
3. **Zod-Schemas** für Form-Definition + Submit + Post Body
4. **Backend-API**: `GET /api/public/feedback/[slug]`, `POST /submit`, `GET /posts`, `POST /posts` — minimal, mit In-Memory Rate-Limit
5. **Frontend `/f/[slug]`**: Form-Renderer + Chat-Liste + Polling — mobile-first
6. **Admin-Liste + Create**: `/admin/feedback` mit JSON-Editor
7. **Admin-Detail + Moderate**: `/admin/feedback/[id]` mit Tabs + Hide-Toggle
8. **Export**: CSV + JSON Endpoints
9. **Closing-Mechanik** + Edge-Cases (closed → 423)
10. **Smoke-Test mit Playwright**: Form-Submit, Chat-Post, Hide, Close, Export
11. **noindex robots.txt + meta**
12. **Nav-Eintrag in `/admin/feedback`** unter "Tools" (auth-only)
Pro Step ein Commit, am Ende Merge-PR an main. Coder-Shift-Empfehlung: **mai-coder** (Standard-Worker, keine Spezial-Skills nötig — kein Realtime, kein WebSocket).
---
## 11. Eigene Eignung für die Implementation
**Knuth (ich)** habe in dieser Shift den Stack vermessen, Patterns gelesen, Schema designt. Ich kenne die Code-Konventionen jetzt out-of-the-box. Wenn m Track-Continuity will, bin ich gut aufgestellt für Coder-Shift-2.
Alternativ: jeder mai-coder mit Memory-Search und Lesen dieses Docs kommt in einer Stunde rein. Keine speziellen Skills nötig.
m / head entscheidet.
---
## 12. References
- Issue: https://mgit.msbls.de/m/flexsiebels.de/issues/63
- Surface-Boundaries-PRD: m/otto `docs/plans/persona-surface-boundaries-prd.md` — Q4 narrow public passt
- Public-Scope-Policy: `website/src/lib/server/public-scope.ts` (issue #59)
- Schema-Helpers: `website/src/lib/server/flexsiebels.ts`, `mbrian.ts`
- Vergleichs-Patterns: Microsoft Forms (Form-Mode), Microsoft Teams Live-Q&A (Chat-Mode), Slido (Live-Polls)