# 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/` öffentlich erreichbar - Antwort-Submit geht über `mana-api` `/api/v1/forms/public//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; // 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; // 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//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//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: `` + `` 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