Create forms-module.md

This commit is contained in:
Till JS 2026-04-28 22:41:45 +02:00
parent 230dfd5dad
commit 907a3add49

364
docs/plans/forms-module.md Normal file
View file

@ -0,0 +1,364 @@
# Forms — Module Plan
## Status (2026-04-28)
**Noch nicht begonnen.** Plan-Dokument; Modul existiert nicht im Repo. Lücke explizit dokumentiert in [`docs/reports/clubdesk-vs-mana-comparison.md:106`](../reports/clubdesk-vs-mana-comparison.md): *"Quiz-Builder vorhanden; kein dediziertes Formular-/Anmelde-System."*
---
## Ziel
Ein Modul `forms`, in dem der Nutzer **eigene Formulare** baut und Antworten sammelt. Kernfrage: *"Wie kriege ich strukturierte Eingaben von anderen Menschen in mein Mana?"*
Use-Cases:
- Vereins-Anmeldung (löst die ClubDesk-Lücke)
- Event-Registrierung mit benutzerdefinierten Feldern (über `mana-events` Share-Links hinaus)
- Lead-Capture auf einer Website (Block in `website`)
- Wöchentliche Mood-/Pulse-Checks ans Team via `broadcasts`
- Persönliche Intake-Formulare (Onboarding-Fragebogen für Coaches/Therapeut:innen)
- "Wishlist"-Formular an Freunde (Geburtstags-Geschenkideen abfragen)
- RSVP-mit-Meta ("Kommst du? Was bringst du mit? Allergien?")
Nicht im Scope:
- **Quizze** — bleibt in `quiz` (Score-Semantik richtig/falsch, Play-Mode, kein Antwort-Sammeln)
- **Self-Surveys** an sich selbst — gehört in `journal` / `augur` / `habits`
- **Voting / Polls mit Live-Ergebnis-Anzeige** — eigenes Pattern, ggf. später als `polls`-Modul
- **Konversationelle Bots**`mana-persona-runner` ist die Heimat dafür; Forms kann allerdings als *Form-as-Conversation* serviert werden (siehe Killer-Feature 4)
## Abgrenzung
- **Nicht `quiz`**: Score-frei. Ein Form-Eintrag hat keine "richtige" Antwort. Trotzdem teilen sich beide Module die Frage-Schema-Engine (siehe Architektur).
- **Nicht `feedback`**: `feedback` ist *zentral* der öffentliche Mana-Feedback-Hub (1 Hub für alle User). `forms` ist *n-zu-1 pro User/Space* — jeder User kann beliebig viele Formulare anlegen.
- **Nicht `website`**: `website` ist Block-Tree CMS für Public-Sites. `forms` ist die Form-Domain — wird in `website` als **Block** gerendert (analog wie `events` als Listing-Block).
- **Nicht `events`**: Events sind Termine. Forms sind Eingabe-Schemas. Ein Event *kann* ein Form als RSVP-Hook haben (1:1-Bindung), aber das Form-Modul kennt Events nicht direkt — das Event-Modul referenziert den Form.
- **Nicht `broadcasts`**: Broadcasts ist Distribution. Forms ist Capture. Ein Broadcast kann ein Form-Link enthalten; bei wiederkehrenden Forms (Killer-Feature 5) sendet `broadcasts` den Link periodisch.
## Architektur-Entscheidung
### Eigenes Modul, geteilte Schema-Engine mit `quiz`
- Eigenes Modul `forms` mit eigenem Launcher-Eintrag, eigener Route, eigener Encryption-Registry-Zeile.
- **Frage-Schema-Engine (Field-Types, Validation, Branching) wird in ein neues Shared-Package `@mana/shared-form-schema` extrahiert**`quiz` und `forms` nutzen es. Mache das in M1 in zwei Schritten: zuerst `forms` baut die Engine inline; sobald sie stabil ist, extrahieren und `quiz` darauf migrieren (separate Lieferung, nicht in diesem Plan).
- **Eine Tabelle pro Belang**: `forms` (das Schema) und `formResponses` (die eingereichten Antworten). Kein gemeinsames JSON.
Begründung: Quiz und Forms haben *fundamental unterschiedliche* Lifecycle-Semantik (Quiz hat `quizAttempts` mit `correct`-Flag, Forms hat `formResponses` ohne Bewertung). Aber die Frage-Definition (Single-Choice, Multi-Choice, Long-Text, Required, etc.) ist identisch. Schema-Sharing reduziert Duplikate; Tabellen-Trennung hält Semantik sauber.
### Eine Public-Render-Pipeline für alle "Public Capture" Modi
Mana hat schon das **Visibility + Unlisted-Sharing System** (`@mana/shared-privacy`). Public-Form-Submission läuft darüber:
- `forms.visibility = 'unlisted'` mit Token → `/forms/<token>` öffentlich erreichbar
- Antwort-Submit geht über `mana-api` `/api/v1/forms/public/<token>/submit` (dem unlisted-System analog)
- Kein neuer Auth-Flow; Public-Submitter müssen *nicht* eingeloggt sein
## Modul-Struktur
```
apps/mana/apps/web/src/lib/modules/forms/
├── types.ts # LocalForm, Form, FormField, FormResponse, FieldType, BranchingRule
├── collections.ts # forms + formResponses Dexie-Tables + Welcome-Seed (1 Beispiel-Form pro Space)
├── queries.ts # useAllForms, useForm(id), useResponses(formId), useResponseStats(formId)
├── stores/
│ ├── forms.svelte.ts # createForm, updateForm, addField, reorderFields, setVisibility, regenerateToken
│ └── responses.svelte.ts # submitResponse, deleteResponse, exportCsv
├── components/
│ ├── FieldEditor.svelte # Drag-reorder, type-switch, required toggle, options editor
│ ├── FieldRenderer.svelte # Public-side: rendert ein Feld als Input
│ ├── FieldPalette.svelte # "Feld hinzufügen"-Picker
│ ├── BranchingEditor.svelte # "Wenn Feld X = Y, zeige Feld Z"
│ ├── ResponsePreview.svelte # Tabellen-Zeile pro Antwort, Klick → Detail
│ ├── ResponseDetailModal.svelte
│ └── FormBlock.svelte # Embed-Komponente für website-builder
├── views/
│ ├── ListView.svelte # Form-Übersicht (Karten mit Antwort-Count)
│ ├── BuilderView.svelte # Form-Editor (Felder + Settings + Logik)
│ ├── ResponsesView.svelte # Antwort-Tabelle mit CSV-Export
│ ├── PublicFormView.svelte # Public-Submit-UI (rendert die definierten Felder)
│ └── SettingsView.svelte # Form-Level Settings (Anonymität, ZK, Auto-Sync-Targets)
├── tools.ts # AI-Tools (siehe AI-Integration)
├── lib/
│ ├── validation.ts # Field-Type-Validation pro Type
│ ├── branching.ts # Conditional-Logic-Resolver
│ └── csv.ts # CSV-Export pure
├── module.config.ts # { appId: 'forms', tables: [{ name: 'forms' }, { name: 'formResponses' }] }
└── index.ts
```
## Daten-Schema
### `LocalForm` (Dexie)
```typescript
export type FieldType =
| 'short_text'
| 'long_text'
| 'single_choice'
| 'multi_choice'
| 'number'
| 'date'
| 'email'
| 'yes_no'
| 'rating' // 1-5 oder 1-10
| 'section' // visueller Trenner, kein Input
| 'consent'; // Pflicht-Häkchen mit Text (DSGVO)
export interface FormField {
id: string; // stabile UUID, von der UI generiert
type: FieldType;
label: string;
helpText?: string;
required: boolean;
options?: { id: string; label: string }[]; // single/multi
config?: {
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
ratingScale?: 5 | 10;
};
}
export interface BranchingRule {
id: string;
ifFieldId: string;
ifOperator: 'equals' | 'not_equals' | 'contains' | 'is_empty';
ifValue?: string | string[];
thenAction: 'show' | 'hide' | 'skip_to';
thenFieldIds?: string[];
thenSkipToFieldId?: string;
}
export interface LocalForm extends BaseRecord {
title: string;
description: string | null;
fields: FormField[]; // ≤ 100 (soft-cap)
branching: BranchingRule[];
status: 'draft' | 'published' | 'closed';
settings: {
submitButtonLabel: string;
successMessage: string;
allowMultipleSubmissions: boolean;
requireEmail: boolean;
anonymous: boolean; // wenn true: keine Submitter-Meta gespeichert
zkMode: boolean; // wenn true: per-Form-Key, Owner sieht nur Aggregate
closedAt?: string;
responseLimit?: number;
autoSync?: {
target: 'contacts' | 'events' | 'feedback' | 'library' | 'space_member';
mapping: Record<string, string>; // formFieldId → targetField
};
};
responseCount: number; // Denormalized counter
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
unlistedExpiresAt?: string;
}
export interface LocalFormResponse extends BaseRecord {
formId: string;
submittedAt: string;
answers: Record<string, string | string[] | number | boolean | null>; // fieldId → value
submitterEmail?: string; // optional, encrypted
submitterName?: string; // optional, encrypted
submitterMeta?: { // encrypted blob
userAgent?: string;
referrer?: string;
ipHash?: string; // server-side gehasht, nie raw IP
};
status: 'new' | 'reviewed' | 'archived' | 'spam';
syncedTargets?: { target: string; recordId: string }[]; // wenn autoSync angewandt
}
```
### Encryption-Registry (`crypto/registry.ts`)
```typescript
forms: {
encrypt: ['title', 'description', 'fields', 'branching', 'settings'],
plaintext: ['status', 'responseCount', 'visibility', 'visibilityChangedAt',
'visibilityChangedBy', 'unlistedToken', 'unlistedExpiresAt',
'createdAt', 'updatedAt', 'spaceId', 'id'],
},
formResponses: {
encrypt: ['answers', 'submitterEmail', 'submitterName', 'submitterMeta'],
plaintext: ['formId', 'submittedAt', 'status', 'syncedTargets',
'createdAt', 'updatedAt', 'spaceId', 'id'],
},
```
`submittedAt` bleibt plaintext (Sortier-Key); `syncedTargets` plaintext, weil es Cross-Module-IDs ohne PII enthält.
## AI-Integration
### Tools im `AI_TOOL_CATALOG`
| Tool | Mode | Zweck |
|---|---|---|
| `forms_create` | propose | Neues Form aus Prompt — Felder werden generiert, User reviewed |
| `forms_add_field` | propose | Einzelnes Feld einfügen (z.B. "füge ein Allergien-Feld hinzu") |
| `forms_publish` | propose | draft → published, generiert unlisted-Token |
| `forms_close` | propose | published → closed (keine neuen Antworten) |
| `forms_list` | auto | Forms eines Spaces auflisten (nur Metadaten) |
| `forms_get_responses` | auto | Antworten als Aggregat (counts, top-values pro Feld) |
| `forms_summarize_responses` | auto | LLM clustert offene Text-Antworten + zieht Themes (analog Augur Living-Oracle) |
### Mission-Runner-Integration
Forms ist ein **prime candidate für Mission-Runner**: "Bau mir bis Donnerstag ein Form für Vereins-Anmeldung mit Pflichtfeldern X, Y, Z, schicke den Link an die Adressliste-A, und melde mir am Sonntag die Antworten." → Mission spannt forms_create + broadcasts_send + (in 5 Tagen) forms_summarize_responses.
## Sync
- `forms` und `formResponses` syncen über die unified `mana-sync`-Engine wie alle anderen Tabellen — pro `appId='forms'` gegruppiert.
- **Public-Submit-Pfad ist eigene Lane**: `mana-api` exponiert `POST /api/v1/forms/public/<token>/submit`, schreibt in `mana_platform.forms_responses` Schema, schickt das Insert *server-seitig* in die Sync-Pipeline (analog `unlisted-snapshots`-Resolver). Der einreichende Nutzer hat keinen Sync-Client.
- Edge-Case **Anonymous-Mode**: bei `settings.anonymous=true` werden `submitterEmail`/`submitterName`/`submitterMeta` server-seitig vor dem Insert verworfen. Dem Owner-Client wird nichts angeliefert, was er nicht selber erst entschlüsseln dürfte.
## Visibility & Unlisted Sharing
Ein Form hat zwei Bedeutungen von "öffentlich":
1. **Form-Schema öffentlich**`visibility='unlisted'` → Token-Link zum *Submit*-View
2. **Antworten öffentlich** — separates Flag `settings.responsesPublic` (rare; standardmäßig **immer privat**)
Default: ein neues Form ist `visibility='private'`. "Form veröffentlichen" bedeutet `published` Status + `unlisted` Visibility-Bump — beides in *einem* Klick im Builder.
Token-Rotation/-Expiry analog zum bestehenden Unlisted-System (`@mana/shared-privacy/unlisted-client`).
## Killer-Features (Differentiator vs. Typeform/Tally)
Diese drei Features rechtfertigen, dass wir Forms statt eines Typeform-Embeds bauen. Alles andere ist Commodity.
### KF1 — AI-Form-from-Prompt
"Bau mir ein Anmeldeformular für unseren Volleyball-Verein mit Spielposition, Trikotgröße und DSGVO-Häkchen." → `forms_create` füllt alle Felder vor; User reviewed im Builder; ein Klick zum Publish.
### KF4 — Form-as-Conversation via Persona-Runner
Der Public-Form-View hat einen Toggle `experience: 'classic' | 'conversation'`. Im Conversation-Mode rendert sich das Form als Chat — `mana-persona-runner` mit einer "Form-Host"-Persona stellt die Fragen, akzeptiert Free-Text-Antworten, mapt sie zurück auf strukturierte Felder. Erfordert `mana-persona-runner` Public-Mode (existiert noch nicht — siehe M5).
### KF7 — Public Sign-up an Space
Form mit `settings.autoSync.target='space_member'` → Submit erzeugt einen Space-Membership-Invite. Löst die ClubDesk-"Online-Anmeldung"-Lücke direkt; der Submitter klickt im Bestätigungs-Email auf "Beitreten" und wird Mitglied.
---
## Milestones
### M1 — Skelett + Field-Engine inline
- Modul registriert in `mana-apps.ts` (icon, color, tier, requiredTier='guest')
- Dexie v53 (oder nächste freie Version) mit `forms` + `formResponses` Tables + Per-Space-Welcome-Seed (ein Beispiel-Form: "Mini-Feedback")
- Encryption-Registry erweitert
- Route `/forms` mountet mit Empty-State
- Field-Engine inline (Validation, Branching) — noch kein Shared-Package-Extract
**Done:** `/forms` lädt; `pnpm validate:all` grün.
### M2 — Builder + CRUD
- `BuilderView`: Drag-reorder Felder via existierende Drag-Primitives, FieldPalette mit den 11 Feldtypen, FieldEditor pro Feld inline
- Field-Type-spezifische Konfig (Options-Editor für Choice-Felder, Min/Max für Number, Rating-Scale-Toggle)
- Form-Settings-Panel (submitButtonLabel, successMessage, requireEmail, allowMultipleSubmissions)
- Draft-Save: every change → `forms.update` (autosave-on-blur Pattern wie in `lasts`)
- ListView mit Karten pro Form, Klick → BuilderView
**Done:** Form mit ≥10 Feldern lässt sich bauen, speichern, neu laden.
### M3 — Public-Submit + Responses-Inbox
- `mana-api` `POST /api/v1/forms/public/<token>/submit` (Hono-Route in `services/mana-api` oder bestehender unified `apps/api`)
- `PublicFormView` rendert die definierten Felder, validiert client-side, submitet
- Bei Submit: Response-Insert in mana_platform → mana-sync pickt's auf → kommt im Owner-Client als neue Zeile in `formResponses` an
- `ResponsesView`: Tabelle mit Submit-Time, Submitter-Email, Snippet pro Antwort; Klick → ResponseDetailModal mit allen Antworten
- CSV-Export pure (kein Server-Roundtrip)
- Status-Workflow `new → reviewed → archived` per Klick in der Tabelle
**Done:** Form publishen, Link in Inkognito-Tab öffnen, Antwort einreichen, Owner sieht sie ≤2 Sek nach Sync.
### M4 — Conditional Logic + Visibility
- `BranchingEditor` mit "Wenn-Dann"-Regeln (UI: pro Feld ein "Logik"-Tab)
- `branching.ts` Resolver berechnet effektiv-sichtbare Felder pro Antwortzustand
- Visibility-System angeschlossen: `<VisibilityPicker>` + `<SharedLinkControls>` im Builder
- Token-Rotation + Expiry
- Resolver `buildFormPublicBlob` in `data/unlisted/resolvers.ts` (whitelist: title, description, fields, branching, settings.submitButtonLabel, settings.successMessage)
- Hard-Block: forms mit `responsesPublic: true` werden trotzdem nie ohne Login serialisiert (Antworten bleiben privat)
**Done:** Branching macht Felder sichtbar/unsichtbar je nach Antwort; Token-Link funktioniert; Token rotieren funktioniert.
### M5 — AI-Tools
- 7 Tools (siehe AI-Integration) in `@mana/shared-ai/src/tools/schemas.ts`
- Webapp-Implementierungen in `forms/tools.ts`
- Server-seitige planner-drift-tests grün
- `forms_summarize_responses` ruft `mana-llm` mit den (entschlüsselten, client-seitig) Antworten auf — keine Klartext-PII ans LLM, ggf. mit Augur-Style "redact emails/names"-Pre-Pass
**Done:** "Bau mir ein Form für …" über Workbench-Chat → fertiges Form im Builder.
### M6 — Encryption + ZK-Mode
- ZK-Mode-Toggle: bei aktiv wird ein per-Form-Key generiert (separater Eintrag in mana-auth Master-Key-Store, *nicht* der User-Master-Key)
- Public-Form-Antworten werden client-seitig im Submitter-Browser mit dem Form-Key (aus dem Public-Schema-Bundle) verschlüsselt → Server speichert nur Ciphertext
- Owner-Client hat den Key (im eigenen, master-key-encrypted Storage), kann lokal entschlüsseln
- Aggregat-Tools (`forms_get_responses` count-mode) funktionieren *ohne* Decrypt — Server kann zählen ohne lesen
- Encrypted-tools-audit grün; Crypto-Registry-Update für `formResponses.answers`
**Done:** ZK-Form publishen, Submit, Server-DB direkt inspizieren → kein Klartext-Antwort sichtbar.
### M7 — Auto-Sync zu Cross-Modulen
- `settings.autoSync` UI im Settings-Tab des Builders
- Bei Submit: Response-Insert + Mapping → schreibt in Target-Modul-Tabelle
- 5 Targets: contacts, events (RSVP), feedback, library, space_member
- `syncedTargets` auf der Response zeigt was passiert ist (UI: kleine Chips in der Response-Detail)
**Done:** Form mit autoSync auf `contacts` ausfüllen → neuer Kontakt erscheint im `/contacts`-Modul.
### M8 — Website-Block + Embed
- `FormBlock.svelte` als Block in `website` Block-Tree CMS
- Block-Config: form_id-Picker (nur eigene Forms des aktiven Spaces)
- SSR-Render auf der public Website rendert das Form direkt (kein Iframe)
- Submit landet im selben Public-API-Endpoint
**Done:** Form als Block in eine Website-Page einfügen, Page öffnen, Form ausfüllen, Antwort kommt im Owner-Client an.
### M9 — Form-as-Conversation (KF4)
- Toggle `experience: 'classic' | 'conversation'` in Form-Settings
- "Form-Host"-Persona in `mana-persona-runner` (read-only, stateless, kein Tool-Access)
- Public-Conversation-View streamt Fragen über `mana-persona-runner` Public-API (neuer Endpoint)
- Antwort-Mapping: LLM extrahiert pro Feldtyp (Single-Choice → match closest option, Number → parse, etc.)
- Fallback bei Mapping-Fehler: classic-Mode-View mit pre-filled Werten
**Done:** Form im Conversation-Mode öffnen → Chat-Bubbles statt Inputs; nach Submit liegt strukturierte Response wie üblich vor.
### M10 — Wiederkehrende Forms (KF5)
- Form-Setting `recurrence: { frequency: 'weekly' | 'monthly', sendVia: 'broadcast', recipients: ContactList }`
- Cron-Job in `mana-notify` oder `mana-ai`: schickt Link periodisch
- Each-Send markiert Antworten mit `cohort: 'YYYY-WW'` für Trend-Analyse
- Cross-Module: bei `autoSync` zu `mood` → speist Augur-Living-Oracle
**Done:** Wöchentlicher Pulse-Check setup, läuft 4 Wochen, ResponseView zeigt Trend.
---
## Out-of-Scope (möglich später)
- **File-Upload-Field** — erfordert Storage-Quota-Management pro Form, eigenes Sub-Plan-Doc
- **Payment-Field** — Stripe/SEPA-Integration; eher Teil von `invoices`-Modul
- **A/B-Testing der Form-Varianten** — Cohort-Mechanik aus M10 reicht aus, A/B kann später drauf
- **Public-Results-Dashboard** — wenn `responsesPublic`, ein Aggregate-View ohne Login
- **Multi-Page Forms** mit Pagination — derzeit single-page, scrollbar; Branching reicht meist
- **Templates-Library** — wenn KF1 (AI-from-Prompt) zieht, brauchen wir keine Template-Galerie
## Nicht-Ziele
- **Logic-Engine wird nicht turing-complete** — nur if/then/skip; keine Formel-Felder ("Antwort A * 2 + Antwort B")
- **Keine Webhooks** zu Drittsystemen — wer das will, scriptet es via `mana-mcp` Tools
- **Kein eigener Server-Dienst**`apps/api` reicht; kein neuer `services/mana-forms`-Container
## Validatoren-Checkliste vor jedem Push
- `pnpm validate:all` grün (turbo + pgSchema + theme + i18n + crypto)
- `pnpm test --filter @mana/web -- forms` grün
- `audit:encrypted-tools` zählt `forms` mit
- `validate:i18n-parity` mit Namespace `forms/` × 5 Locales aligned
## Vorbild-Module für Pattern-Recyceln
- **`quiz`** — Frage-Schema, Field-Types-Pattern (extrahieren in M1-Folge-Lieferung)
- **`lasts`** — Settings-Store, DueBanner für "Du hast 3 neue Antworten", autosave-on-blur
- **`augur`** — Living-Oracle für `forms_summarize_responses` (Theme-Clustering)
- **`events`** Share-Links — Public-Submit-Pfad-Vorbild für Anmeldung
- **`website`** Block-Tree — `FormBlock.svelte` analog zu existierenden Blocks