20 KiB
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: "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-eventsShare-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-runnerist 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:feedbackist zentral der öffentliche Mana-Feedback-Hub (1 Hub für alle User).formsist n-zu-1 pro User/Space — jeder User kann beliebig viele Formulare anlegen. - Nicht
website:websiteist Block-Tree CMS für Public-Sites.formsist die Form-Domain — wird inwebsiteals Block gerendert (analog wieeventsals 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) sendetbroadcastsden Link periodisch.
Architektur-Entscheidung
Eigenes Modul, geteilte Schema-Engine mit quiz
- Eigenes Modul
formsmit 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-schemaextrahiert —quizundformsnutzen es. Mache das in M1 in zwei Schritten: zuerstformsbaut die Engine inline; sobald sie stabil ist, extrahieren undquizdarauf migrieren (separate Lieferung, nicht in diesem Plan). - Eine Tabelle pro Belang:
forms(das Schema) undformResponses(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)
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)
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
formsundformResponsessyncen über die unifiedmana-sync-Engine wie alle anderen Tabellen — proappId='forms'gegruppiert.- Public-Submit-Pfad ist eigene Lane:
mana-apiexponiertPOST /api/v1/forms/public/<token>/submit, schreibt inmana_platform.forms_responsesSchema, schickt das Insert server-seitig in die Sync-Pipeline (analogunlisted-snapshots-Resolver). Der einreichende Nutzer hat keinen Sync-Client. - Edge-Case Anonymous-Mode: bei
settings.anonymous=truewerdensubmitterEmail/submitterName/submitterMetaserver-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":
- Form-Schema öffentlich —
visibility='unlisted'→ Token-Link zum Submit-View - 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+formResponsesTables + Per-Space-Welcome-Seed (ein Beispiel-Form: "Mini-Feedback") - Encryption-Registry erweitert
- Route
/formsmountet 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 inlasts) - 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-apiPOST /api/v1/forms/public/<token>/submit(Hono-Route inservices/mana-apioder bestehender unifiedapps/api)PublicFormViewrendert 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
formResponsesan 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 → archivedper 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
BranchingEditormit "Wenn-Dann"-Regeln (UI: pro Feld ein "Logik"-Tab)branching.tsResolver berechnet effektiv-sichtbare Felder pro Antwortzustand- Visibility-System angeschlossen:
<VisibilityPicker>+<SharedLinkControls>im Builder - Token-Rotation + Expiry
- Resolver
buildFormPublicBlobindata/unlisted/resolvers.ts(whitelist: title, description, fields, branching, settings.submitButtonLabel, settings.successMessage) - Hard-Block: forms mit
responsesPublic: truewerden 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_responsesruftmana-llmmit 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_responsescount-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.autoSyncUI im Settings-Tab des Builders- Bei Submit: Response-Insert + Mapping → schreibt in Target-Modul-Tabelle
- 5 Targets: contacts, events (RSVP), feedback, library, space_member
syncedTargetsauf 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.svelteals Block inwebsiteBlock-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-runnerPublic-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-notifyodermana-ai: schickt Link periodisch - Each-Send markiert Antworten mit
cohort: 'YYYY-WW'für Trend-Analyse - Cross-Module: bei
autoSynczumood→ 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-mcpTools - Kein eigener Server-Dienst —
apps/apireicht; kein neuerservices/mana-forms-Container
Validatoren-Checkliste vor jedem Push
pnpm validate:allgrün (turbo + pgSchema + theme + i18n + crypto)pnpm test --filter @mana/web -- formsgrünaudit:encrypted-toolszähltformsmitvalidate:i18n-paritymit Namespaceforms/× 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-bluraugur— Living-Oracle fürforms_summarize_responses(Theme-Clustering)eventsShare-Links — Public-Submit-Pfad-Vorbild für AnmeldungwebsiteBlock-Tree —FormBlock.svelteanalog zu existierenden Blocks