diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 747a1d063..1e5992c37 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -99,6 +99,7 @@ import type { } from '../../modules/writing/types'; import type { LocalComicStory, LocalComicCharacter } from '../../modules/comic/types'; import type { LocalAugurEntry } from '../../modules/augur/types'; +import type { LocalForm, LocalFormResponse } from '../../modules/forms/types'; export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── @@ -959,6 +960,30 @@ export const ENCRYPTION_REGISTRY: Record = { 'footer', 'defaultTerms', ]), + + // ─── Forms ────────────────────────────────────────────── + // User-defined questionnaires + the answers people submit. Plan: see + // docs/plans/forms-module.md. The schema (title, description, fields, + // branching, settings) carries the full text of every prompt the user + // wrote — encrypted. Plaintext (intentional): status drives the + // draft/published/closed filter; responseCount is a denormalized UI + // counter; visibility/visibilityChanged*/unlistedToken/unlistedExpiresAt + // are the share-routing surface the server-side public-submit + // endpoint must read without the master key. + forms: entry(['title', 'description', 'fields', 'branching', 'settings']), + + // Answers travel encrypted as one blob — `answers` is a free-form + // Record that may carry PII (names, emails, free text). + // `submitterEmail` / `submitterName` / `submitterMeta` are encrypted + // separately so the audit log can selectively decrypt only what the + // owner asked for. Plaintext: formId (FK), submittedAt (sort), status + // (review filter), syncedTargets (no PII, just internal IDs). + formResponses: entry([ + 'answers', + 'submitterEmail', + 'submitterName', + 'submitterMeta', + ]), }; /** diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index a550c03eb..e116327f2 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -1430,6 +1430,21 @@ db.version(56).stores({ articleExtractPickup: 'id, itemId, _updatedAtIndex', }); +// v57 — Forms module M1 skeleton (docs/plans/forms-module.md). +// Two tables: `forms` carries the schema definition (fields, branching, +// settings) plus the visibility/unlisted-token surface so the public +// share endpoint can resolve a token to a form without decrypting; the +// indexed `status` powers the draft/published/closed filter and +// `_updatedAtIndex` keeps the workbench sort cheap. `formResponses` +// holds one row per submission — `[formId+status]` is the hot index for +// the responses tab (per-form, filtered by review state); `formId` +// alone is needed for the cross-status response feed; `submittedAt` +// drives the chronological default sort. +db.version(57).stores({ + forms: 'id, status, _updatedAtIndex', + formResponses: 'id, formId, status, submittedAt, _updatedAtIndex, [formId+status]', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 82bfd3521..61630d0c5 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -109,6 +109,7 @@ import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config'; import { writingModuleConfig } from '$lib/modules/writing/module.config'; import { comicModuleConfig } from '$lib/modules/comic/module.config'; import { augurModuleConfig } from '$lib/modules/augur/module.config'; +import { formsModuleConfig } from '$lib/modules/forms/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ @@ -174,6 +175,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ writingModuleConfig, comicModuleConfig, augurModuleConfig, + formsModuleConfig, aiModuleConfig, ]; diff --git a/apps/mana/apps/web/src/lib/data/seeds/forms.ts b/apps/mana/apps/web/src/lib/data/seeds/forms.ts new file mode 100644 index 000000000..ac13852b8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/seeds/forms.ts @@ -0,0 +1,67 @@ +/** + * Per-Space "Welcome" seed for the Forms module. + * + * Drops a single draft form into each Space the first time it is + * activated, so the empty state is replaced by a concrete example + * users can edit, publish, or delete. Idempotent via deterministic id — + * see docs/plans/workbench-seeding-cleanup.md. + * + * Plan: docs/plans/forms-module.md M1. + */ + +import { db } from '../database'; +import { encryptRecord } from '../crypto'; +import { registerSpaceSeed } from '../scope/per-space-seeds'; +import { DEFAULT_FORM_SETTINGS } from '$lib/modules/forms/types'; +import type { FormField, LocalForm } from '$lib/modules/forms/types'; + +const TABLE = 'forms'; + +export function formsWelcomeSeedId(spaceId: string): string { + return `seed-welcome-${spaceId}`; +} + +const exampleFields: FormField[] = [ + { + id: 'seed-name', + type: 'short_text', + label: 'Wie heißt du?', + required: true, + }, + { + id: 'seed-mood', + type: 'rating', + label: 'Wie geht es dir heute?', + required: false, + config: { ratingScale: 5 }, + }, + { + id: 'seed-note', + type: 'long_text', + label: 'Magst du etwas teilen?', + helpText: 'Ein Satz reicht.', + required: false, + }, +]; + +registerSpaceSeed('forms-welcome', async (spaceId) => { + const id = formsWelcomeSeedId(spaceId); + const existing = await db.table(TABLE).get(id); + if (existing) return; + + const row: LocalForm = { + id, + spaceId, + title: 'Beispiel-Formular', + description: 'Ein Mini-Pulse-Check als Startpunkt. Bearbeite oder lösche es jederzeit.', + fields: exampleFields, + branching: [], + status: 'draft', + settings: { ...DEFAULT_FORM_SETTINGS }, + responseCount: 0, + visibility: 'private', + } as LocalForm; + + await encryptRecord(TABLE, row); + await db.table(TABLE).add(row); +}); diff --git a/apps/mana/apps/web/src/lib/data/seeds/index.ts b/apps/mana/apps/web/src/lib/data/seeds/index.ts index c6288409b..3ed65d268 100644 --- a/apps/mana/apps/web/src/lib/data/seeds/index.ts +++ b/apps/mana/apps/web/src/lib/data/seeds/index.ts @@ -19,3 +19,6 @@ import './workbench-home'; // Side-effect: registers `lasts-welcome` per-space-seed. import './lasts'; + +// Side-effect: registers `forms-welcome` per-space-seed. +import './forms'; diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json new file mode 100644 index 000000000..eeb5ea9fe --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json @@ -0,0 +1,27 @@ +{ + "app": { + "title": "Formulare", + "tagline": "Eigene Formulare bauen und Antworten sammeln." + }, + "list": { + "emptyAll": "Noch keine Formulare angelegt.", + "emptyHint": "Tipp: Tippe oben einen Titel und drücke Enter.", + "emptySearch": "Keine Treffer für deine Suche.", + "searchPlaceholder": "Formulare durchsuchen ...", + "fieldCount": "{n} Felder", + "responseCount": "{n} Antworten", + "justNow": "gerade eben", + "minutesAgo": "vor {n} min", + "hoursAgo": "vor {n} h", + "daysAgo": "vor {n} Tagen" + }, + "quickAdd": { + "placeholder": "Neues Formular ... (Enter)", + "ariaLabel": "Formular-Titel" + }, + "status": { + "draft": "Entwurf", + "published": "Veröffentlicht", + "closed": "Geschlossen" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json new file mode 100644 index 000000000..43e96a4e7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json @@ -0,0 +1,27 @@ +{ + "app": { + "title": "Forms", + "tagline": "Build your own forms and collect responses." + }, + "list": { + "emptyAll": "No forms yet.", + "emptyHint": "Tip: type a title above and hit Enter.", + "emptySearch": "No matches for your search.", + "searchPlaceholder": "Search forms ...", + "fieldCount": "{n} fields", + "responseCount": "{n} responses", + "justNow": "just now", + "minutesAgo": "{n} min ago", + "hoursAgo": "{n} h ago", + "daysAgo": "{n} days ago" + }, + "quickAdd": { + "placeholder": "New form ... (Enter)", + "ariaLabel": "Form title" + }, + "status": { + "draft": "Draft", + "published": "Published", + "closed": "Closed" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json new file mode 100644 index 000000000..c3dfe4c90 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json @@ -0,0 +1,27 @@ +{ + "app": { + "title": "Formularios", + "tagline": "Crea formularios y recoge respuestas." + }, + "list": { + "emptyAll": "Todavía no hay formularios.", + "emptyHint": "Sugerencia: escribe un título arriba y pulsa Enter.", + "emptySearch": "Sin resultados para tu búsqueda.", + "searchPlaceholder": "Buscar formularios ...", + "fieldCount": "{n} campos", + "responseCount": "{n} respuestas", + "justNow": "ahora mismo", + "minutesAgo": "hace {n} min", + "hoursAgo": "hace {n} h", + "daysAgo": "hace {n} días" + }, + "quickAdd": { + "placeholder": "Nuevo formulario ... (Enter)", + "ariaLabel": "Título del formulario" + }, + "status": { + "draft": "Borrador", + "published": "Publicado", + "closed": "Cerrado" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json new file mode 100644 index 000000000..990967e05 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json @@ -0,0 +1,27 @@ +{ + "app": { + "title": "Formulaires", + "tagline": "Crée tes propres formulaires et collecte des réponses." + }, + "list": { + "emptyAll": "Aucun formulaire pour le moment.", + "emptyHint": "Astuce : tape un titre ci-dessus et appuie sur Entrée.", + "emptySearch": "Aucun résultat pour ta recherche.", + "searchPlaceholder": "Rechercher des formulaires ...", + "fieldCount": "{n} champs", + "responseCount": "{n} réponses", + "justNow": "à l'instant", + "minutesAgo": "il y a {n} min", + "hoursAgo": "il y a {n} h", + "daysAgo": "il y a {n} jours" + }, + "quickAdd": { + "placeholder": "Nouveau formulaire ... (Entrée)", + "ariaLabel": "Titre du formulaire" + }, + "status": { + "draft": "Brouillon", + "published": "Publié", + "closed": "Clôturé" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json new file mode 100644 index 000000000..bdc6d3194 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json @@ -0,0 +1,27 @@ +{ + "app": { + "title": "Moduli", + "tagline": "Crea i tuoi moduli e raccogli risposte." + }, + "list": { + "emptyAll": "Ancora nessun modulo.", + "emptyHint": "Suggerimento: digita un titolo sopra e premi Invio.", + "emptySearch": "Nessun risultato per la tua ricerca.", + "searchPlaceholder": "Cerca moduli ...", + "fieldCount": "{n} campi", + "responseCount": "{n} risposte", + "justNow": "proprio adesso", + "minutesAgo": "{n} min fa", + "hoursAgo": "{n} h fa", + "daysAgo": "{n} giorni fa" + }, + "quickAdd": { + "placeholder": "Nuovo modulo ... (Invio)", + "ariaLabel": "Titolo del modulo" + }, + "status": { + "draft": "Bozza", + "published": "Pubblicato", + "closed": "Chiuso" + } +} diff --git a/apps/mana/apps/web/src/lib/modules/forms/ListView.svelte b/apps/mana/apps/web/src/lib/modules/forms/ListView.svelte new file mode 100644 index 000000000..893605b41 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/ListView.svelte @@ -0,0 +1,283 @@ + + + +
+
+

{$_('forms.app.title', { default: 'Formulare' })}

+

+ {$_('forms.app.tagline', { + default: 'Eigene Formulare bauen und Antworten sammeln.', + })} +

+
+ +
+ +
+ +
+ +
+ + {#if filtered.length === 0} + {#if hasActiveSceneScope()} + + {:else if forms.length === 0} +
+

+ {$_('forms.list.emptyAll', { default: 'Noch keine Formulare angelegt.' })} +

+

+ {$_('forms.list.emptyHint', { + default: 'Tipp: Tippe oben einen Titel und drücke Enter.', + })} +

+
+ {:else} +
+

+ {$_('forms.list.emptySearch', { default: 'Keine Treffer für deine Suche.' })} +

+
+ {/if} + {:else} +
    + {#each filtered as form (form.id)} + + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/forms/collections.ts b/apps/mana/apps/web/src/lib/modules/forms/collections.ts new file mode 100644 index 000000000..1e1c21180 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/collections.ts @@ -0,0 +1,5 @@ +import { db } from '$lib/data/database'; +import type { LocalForm, LocalFormResponse } from './types'; + +export const formTable = db.table('forms'); +export const formResponseTable = db.table('formResponses'); diff --git a/apps/mana/apps/web/src/lib/modules/forms/index.ts b/apps/mana/apps/web/src/lib/modules/forms/index.ts new file mode 100644 index 000000000..39cf7f486 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/index.ts @@ -0,0 +1,46 @@ +// ─── Stores ────────────────────────────────────────────── +export { formsStore } from './stores/forms.svelte'; +export { responsesStore } from './stores/responses.svelte'; + +// ─── Queries ───────────────────────────────────────────── +export { + useAllForms, + useFormsByStatus, + useFormResponses, + useResponsesByStatus, + toForm, + toFormResponse, + searchForms, +} from './queries'; + +// ─── Collections ───────────────────────────────────────── +export { formTable, formResponseTable } from './collections'; + +// ─── Types ─────────────────────────────────────────────── +export { + FIELD_TYPE_LABELS, + FORM_STATUS_LABELS, + RESPONSE_STATUS_LABELS, + DEFAULT_FORM_SETTINGS, +} from './types'; +export type { + LocalForm, + Form, + FormStatus, + FormField, + FieldType, + FieldOption, + FieldConfig, + FormSettings, + BranchingRule, + BranchOperator, + BranchAction, + AutoSyncConfig, + AutoSyncTarget, + LocalFormResponse, + FormResponse, + ResponseStatus, + AnswerValue, + SubmitterMeta, + SyncedTarget, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/forms/module.config.ts b/apps/mana/apps/web/src/lib/modules/forms/module.config.ts new file mode 100644 index 000000000..52edeca0c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const formsModuleConfig: ModuleConfig = { + appId: 'forms', + tables: [{ name: 'forms' }, { name: 'formResponses' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/forms/queries.ts b/apps/mana/apps/web/src/lib/modules/forms/queries.ts new file mode 100644 index 000000000..6e5eb77d5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/queries.ts @@ -0,0 +1,109 @@ +import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; +import { deriveUpdatedAt } from '$lib/data/sync'; +import { scopedForModule } from '$lib/data/scope'; +import { decryptRecords } from '$lib/data/crypto'; +import { DEFAULT_FORM_SETTINGS } from './types'; +import type { + Form, + FormResponse, + FormStatus, + LocalForm, + LocalFormResponse, + ResponseStatus, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toForm(local: LocalForm): Form { + return { + id: local.id, + title: local.title, + description: local.description, + fields: local.fields ?? [], + branching: local.branching ?? [], + status: local.status, + settings: { ...DEFAULT_FORM_SETTINGS, ...(local.settings ?? {}) }, + responseCount: local.responseCount ?? 0, + visibility: local.visibility ?? 'private', + unlistedToken: local.unlistedToken ?? '', + unlistedExpiresAt: local.unlistedExpiresAt ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: deriveUpdatedAt(local), + }; +} + +export function toFormResponse(local: LocalFormResponse): FormResponse { + return { + id: local.id, + formId: local.formId, + submittedAt: local.submittedAt, + answers: local.answers ?? {}, + submitterEmail: local.submitterEmail ?? null, + submitterName: local.submitterName ?? null, + submitterMeta: local.submitterMeta ?? null, + status: local.status, + syncedTargets: local.syncedTargets ?? [], + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: deriveUpdatedAt(local), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllForms() { + return useScopedLiveQuery(async () => { + const visible = (await scopedForModule('forms', 'forms').toArray()).filter( + (f) => !f.deletedAt + ); + const decrypted = await decryptRecords('forms', visible); + return decrypted.map(toForm).sort(compareForms); + }, [] as Form[]); +} + +export function useFormsByStatus(status: FormStatus) { + return useScopedLiveQuery(async () => { + const visible = (await scopedForModule('forms', 'forms').toArray()).filter( + (f) => !f.deletedAt && f.status === status + ); + const decrypted = await decryptRecords('forms', visible); + return decrypted.map(toForm).sort(compareForms); + }, [] as Form[]); +} + +export function useFormResponses(formId: string) { + return useScopedLiveQuery(async () => { + const visible = ( + await scopedForModule('forms', 'formResponses').toArray() + ).filter((r) => !r.deletedAt && r.formId === formId); + const decrypted = await decryptRecords('formResponses', visible); + return decrypted.map(toFormResponse).sort((a, b) => b.submittedAt.localeCompare(a.submittedAt)); + }, [] as FormResponse[]); +} + +export function useResponsesByStatus(formId: string, status: ResponseStatus) { + return useScopedLiveQuery(async () => { + const visible = ( + await scopedForModule('forms', 'formResponses').toArray() + ).filter((r) => !r.deletedAt && r.formId === formId && r.status === status); + const decrypted = await decryptRecords('formResponses', visible); + return decrypted.map(toFormResponse).sort((a, b) => b.submittedAt.localeCompare(a.submittedAt)); + }, [] as FormResponse[]); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +function compareForms(a: Form, b: Form): number { + // Drafts at the top while editing, then published, then closed. + const order: Record = { draft: 0, published: 1, closed: 2 }; + if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status]; + return b.updatedAt.localeCompare(a.updatedAt); +} + +export function searchForms(forms: Form[], query: string): Form[] { + if (!query.trim()) return forms; + const q = query.toLowerCase(); + return forms.filter((f) => { + const haystack = [f.title, f.description].filter(Boolean).join(' ').toLowerCase(); + return haystack.includes(q); + }); +} diff --git a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts new file mode 100644 index 000000000..9a616dacb --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts @@ -0,0 +1,93 @@ +import { formTable } from '../collections'; +import { toForm } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; +import { DEFAULT_FORM_SETTINGS } from '../types'; +import type { BranchingRule, Form, FormField, FormSettings, FormStatus, LocalForm } from '../types'; + +function nowIso(): string { + return new Date().toISOString(); +} + +export const formsStore = { + async createForm(data: { + title: string; + description?: string | null; + fields?: FormField[]; + branching?: BranchingRule[]; + settings?: Partial; + }): Promise
{ + const id = crypto.randomUUID(); + const newLocal: LocalForm = { + id, + title: data.title, + description: data.description ?? null, + fields: data.fields ?? [], + branching: data.branching ?? [], + status: 'draft', + settings: { ...DEFAULT_FORM_SETTINGS, ...(data.settings ?? {}) }, + responseCount: 0, + visibility: 'private', + }; + + const plaintextSnapshot = toForm(newLocal); + await encryptRecord('forms', newLocal); + await formTable.add(newLocal); + return plaintextSnapshot; + }, + + async updateForm( + id: string, + data: Partial> + ) { + const diff: Partial = { ...data }; + await encryptRecord('forms', diff); + await formTable.update(id, diff); + }, + + async setStatus(id: string, status: FormStatus) { + await formTable.update(id, { status }); + }, + + async deleteForm(id: string) { + await formTable.update(id, { deletedAt: nowIso() }); + }, + + async addField(id: string, field: FormField) { + const form = await formTable.get(id); + if (!form) return; + const fields = [...(form.fields ?? []), field]; + const diff: Partial = { fields }; + await encryptRecord('forms', diff); + await formTable.update(id, diff); + }, + + async updateField(id: string, fieldId: string, patch: Partial) { + const form = await formTable.get(id); + if (!form) return; + const fields = (form.fields ?? []).map((f) => + f.id === fieldId ? { ...f, ...patch, id: f.id } : f + ); + const diff: Partial = { fields }; + await encryptRecord('forms', diff); + await formTable.update(id, diff); + }, + + async removeField(id: string, fieldId: string) { + const form = await formTable.get(id); + if (!form) return; + const fields = (form.fields ?? []).filter((f) => f.id !== fieldId); + const diff: Partial = { fields }; + await encryptRecord('forms', diff); + await formTable.update(id, diff); + }, + + async reorderFields(id: string, fieldIds: string[]) { + const form = await formTable.get(id); + if (!form) return; + const byId = new Map((form.fields ?? []).map((f) => [f.id, f])); + const reordered = fieldIds.map((fid) => byId.get(fid)).filter((f): f is FormField => !!f); + const diff: Partial = { fields: reordered }; + await encryptRecord('forms', diff); + await formTable.update(id, diff); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/forms/stores/responses.svelte.ts b/apps/mana/apps/web/src/lib/modules/forms/stores/responses.svelte.ts new file mode 100644 index 000000000..2cbb4b369 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/stores/responses.svelte.ts @@ -0,0 +1,58 @@ +import { formResponseTable, formTable } from '../collections'; +import { toFormResponse } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; +import type { AnswerValue, FormResponse, LocalFormResponse, ResponseStatus } from '../types'; + +function nowIso(): string { + return new Date().toISOString(); +} + +export const responsesStore = { + /** + * Record a response submitted via the in-app builder preview. The + * public-submit pipeline (M3) writes server-side and round-trips + * through mana-sync, so it does not call this store. + */ + async submitResponse(data: { + formId: string; + answers: Record; + submitterEmail?: string | null; + submitterName?: string | null; + }): Promise { + const id = crypto.randomUUID(); + const now = nowIso(); + const newLocal: LocalFormResponse = { + id, + formId: data.formId, + submittedAt: now, + answers: data.answers, + submitterEmail: data.submitterEmail ?? undefined, + submitterName: data.submitterName ?? undefined, + status: 'new', + }; + + const plaintextSnapshot = toFormResponse(newLocal); + await encryptRecord('formResponses', newLocal); + await formResponseTable.add(newLocal); + + // Bump denormalized counter on the parent form. Read-modify-write + // is fine here — collisions resolve via field-level LWW in sync, + // and the count is a UI-only signal (re-deriveable from a query). + const parent = await formTable.get(data.formId); + if (parent) { + await formTable.update(data.formId, { + responseCount: (parent.responseCount ?? 0) + 1, + }); + } + + return plaintextSnapshot; + }, + + async setStatus(id: string, status: ResponseStatus) { + await formResponseTable.update(id, { status }); + }, + + async deleteResponse(id: string) { + await formResponseTable.update(id, { deletedAt: nowIso() }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/forms/types.ts b/apps/mana/apps/web/src/lib/modules/forms/types.ts new file mode 100644 index 000000000..04c975788 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/types.ts @@ -0,0 +1,204 @@ +/** + * Forms module types. + * + * Two tables: `forms` (the schema definition + settings) and + * `formResponses` (one row per submitted answer set). Plan: see + * docs/plans/forms-module.md. + * + * Field semantics intentionally diverge from the Quiz module's options + * (no `isCorrect` flag, no answer scoring). The plan to extract a shared + * `@mana/shared-form-schema` package is a follow-up to M1. + */ + +import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; + +// ─── Field-Type Catalogue ─────────────────────────────────── + +export type FieldType = + | 'short_text' + | 'long_text' + | 'single_choice' + | 'multi_choice' + | 'number' + | 'date' + | 'email' + | 'yes_no' + | 'rating' + | 'section' + | 'consent'; + +export interface FieldOption { + id: string; + label: string; +} + +export interface FieldConfig { + minLength?: number; + maxLength?: number; + min?: number; + max?: number; + ratingScale?: 5 | 10; +} + +export interface FormField { + id: string; + type: FieldType; + label: string; + helpText?: string; + required: boolean; + options?: FieldOption[]; + config?: FieldConfig; +} + +// ─── Branching ────────────────────────────────────────────── + +export type BranchOperator = 'equals' | 'not_equals' | 'contains' | 'is_empty'; +export type BranchAction = 'show' | 'hide' | 'skip_to'; + +export interface BranchingRule { + id: string; + ifFieldId: string; + ifOperator: BranchOperator; + ifValue?: string | string[]; + thenAction: BranchAction; + thenFieldIds?: string[]; + thenSkipToFieldId?: string; +} + +// ─── Settings ─────────────────────────────────────────────── + +export type AutoSyncTarget = 'contacts' | 'events' | 'feedback' | 'library' | 'space_member'; + +export interface AutoSyncConfig { + target: AutoSyncTarget; + mapping: Record; +} + +export interface FormSettings { + submitButtonLabel: string; + successMessage: string; + allowMultipleSubmissions: boolean; + requireEmail: boolean; + anonymous: boolean; + zkMode: boolean; + closedAt?: string; + responseLimit?: number; + autoSync?: AutoSyncConfig; + responsesPublic?: boolean; +} + +export type FormStatus = 'draft' | 'published' | 'closed'; + +// ─── Local (Dexie) Records ────────────────────────────────── + +export interface LocalForm extends BaseRecord { + title: string; + description: string | null; + fields: FormField[]; + branching: BranchingRule[]; + status: FormStatus; + settings: FormSettings; + responseCount: number; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; + unlistedExpiresAt?: string | null; +} + +export type ResponseStatus = 'new' | 'reviewed' | 'archived' | 'spam'; + +export type AnswerValue = string | string[] | number | boolean | null; + +export interface SubmitterMeta { + userAgent?: string; + referrer?: string; + ipHash?: string; +} + +export interface SyncedTarget { + target: AutoSyncTarget; + recordId: string; +} + +export interface LocalFormResponse extends BaseRecord { + formId: string; + submittedAt: string; + answers: Record; + submitterEmail?: string; + submitterName?: string; + submitterMeta?: SubmitterMeta; + status: ResponseStatus; + syncedTargets?: SyncedTarget[]; +} + +// ─── Domain Types ─────────────────────────────────────────── + +export interface Form { + id: string; + title: string; + description: string | null; + fields: FormField[]; + branching: BranchingRule[]; + status: FormStatus; + settings: FormSettings; + responseCount: number; + visibility: VisibilityLevel; + unlistedToken: string; + unlistedExpiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface FormResponse { + id: string; + formId: string; + submittedAt: string; + answers: Record; + submitterEmail: string | null; + submitterName: string | null; + submitterMeta: SubmitterMeta | null; + status: ResponseStatus; + syncedTargets: SyncedTarget[]; + createdAt: string; + updatedAt: string; +} + +// ─── Constants ────────────────────────────────────────────── + +export const FIELD_TYPE_LABELS: Record = { + short_text: { de: 'Kurzer Text', en: 'Short text' }, + long_text: { de: 'Langer Text', en: 'Long text' }, + single_choice: { de: 'Einfachauswahl', en: 'Single choice' }, + multi_choice: { de: 'Mehrfachauswahl', en: 'Multiple choice' }, + number: { de: 'Zahl', en: 'Number' }, + date: { de: 'Datum', en: 'Date' }, + email: { de: 'E-Mail', en: 'Email' }, + yes_no: { de: 'Ja / Nein', en: 'Yes / No' }, + rating: { de: 'Bewertung', en: 'Rating' }, + section: { de: 'Abschnitt', en: 'Section' }, + consent: { de: 'Einwilligung', en: 'Consent' }, +}; + +export const FORM_STATUS_LABELS: Record = { + draft: { de: 'Entwurf', en: 'Draft' }, + published: { de: 'Veröffentlicht', en: 'Published' }, + closed: { de: 'Geschlossen', en: 'Closed' }, +}; + +export const RESPONSE_STATUS_LABELS: Record = { + new: { de: 'Neu', en: 'New' }, + reviewed: { de: 'Gesichtet', en: 'Reviewed' }, + archived: { de: 'Archiviert', en: 'Archived' }, + spam: { de: 'Spam', en: 'Spam' }, +}; + +export const DEFAULT_FORM_SETTINGS: FormSettings = { + submitButtonLabel: 'Senden', + successMessage: 'Danke! Deine Antwort wurde übermittelt.', + allowMultipleSubmissions: false, + requireEmail: false, + anonymous: false, + zkMode: false, +}; diff --git a/apps/mana/apps/web/src/routes/(app)/forms/+page.svelte b/apps/mana/apps/web/src/routes/(app)/forms/+page.svelte new file mode 100644 index 000000000..1b2828932 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/forms/+page.svelte @@ -0,0 +1,12 @@ + + + + Forms - Mana + + + + {}} goBack={() => history.back()} params={{}} /> +