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

17 KiB
Raw Blame History

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)

-- 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):

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: m/flexsiebels.de#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)