feat(forms): M1 skeleton — module + Dexie v57 + welcome-seed

Erste Lieferung des Forms-Moduls (docs/plans/forms-module.md M1):

- Modul-Struktur unter src/lib/modules/forms/: types (LocalForm,
  LocalFormResponse, 11 Field-Types, BranchingRule, FormSettings),
  module.config, collections, queries (live + type-converters),
  stores (forms-CRUD inkl. add/update/remove/reorderFields,
  responses-submit mit denormalized responseCount-bump),
  ListView mit Quick-Create + Search + 3-fach Empty-State.
- Dexie v57: forms (id, status, _updatedAtIndex) + formResponses
  (id, formId, status, submittedAt, _updatedAtIndex, [formId+status]).
- Encryption-Registry typed entries: title/description/fields/branching/
  settings auf forms; answers/submitterEmail/submitterName/submitterMeta
  auf formResponses. Status, formId, submittedAt, responseCount,
  visibility, unlistedToken bleiben plaintext (Routing- + Sort-Felder).
- Per-Space-Welcome-Seed mit Beispiel-Formular (3 Felder), wired in
  data/seeds/index.ts.
- Route /forms via RoutePage (appId='forms').
- i18n-Namespace forms/ × 5 Locales (de/en/es/fr/it).

App-Registry-Eintrag (APP_ICONS.forms + MANA_APPS) ist bereits in
6d193a9fa gelandet (paralleler app-registry-polish-Commit).

Validatoren grün: validate:turbo, validate:pg-schema,
validate:i18n-parity (77 namespaces × 5), validate:theme-{parity,
utilities,variables}, audit:encrypted-tools (23 tools, M1 hat keine),
svelte-check 0/0/0 über 7680 Files. check:crypto: 213 Tables (+2
sind meine), 3 Violations sind pre-existing dead entries.

LOCAL TIER PATCH: requiredTier='guest' mit revert-Marker vor Release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 23:01:05 +02:00
parent 6d193a9fa7
commit 75d9207ff2
19 changed files with 1063 additions and 0 deletions

View file

@ -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<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -959,6 +960,30 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
'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<LocalForm>(['title', 'description', 'fields', 'branching', 'settings']),
// Answers travel encrypted as one blob — `answers` is a free-form
// Record<fieldId, value> 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<LocalFormResponse>([
'answers',
'submitterEmail',
'submitterName',
'submitterMeta',
]),
};
/**

View file

@ -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

View file

@ -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,
];

View file

@ -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);
});

View file

@ -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';

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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é"
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,283 @@
<!--
Forms — Workbench ListView (M1 skeleton)
Renders the active Space's forms with an empty-state for new Spaces
and a Quick-Create input. Builder + responses views land in M2/M3.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
import { useAllForms, searchForms } from './queries';
import { formsStore } from './stores/forms.svelte';
import { FORM_STATUS_LABELS } from './types';
import type { Form } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate: _navigate, goBack: _goBack, params: _params }: ViewProps = $props();
const forms$ = useAllForms();
const forms = $derived(forms$.value);
let searchQuery = $state('');
const filtered = $derived(searchForms(forms, searchQuery));
let newTitle = $state('');
async function handleQuickCreate(e: KeyboardEvent) {
if (e.key !== 'Enter' || !newTitle.trim()) return;
e.preventDefault();
const title = newTitle.trim();
newTitle = '';
const created = await formsStore.createForm({ title });
goto(`/forms/${created.id}`);
}
function openForm(form: Form) {
goto(`/forms/${form.id}`);
}
function relativeTime(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime();
const minutes = Math.round(diffMs / 60000);
if (minutes < 1) return $_('forms.list.justNow', { default: 'gerade eben' });
if (minutes < 60)
return $_('forms.list.minutesAgo', {
default: 'vor {n} min',
values: { n: minutes },
});
const hours = Math.round(minutes / 60);
if (hours < 24)
return $_('forms.list.hoursAgo', {
default: 'vor {n} h',
values: { n: hours },
});
const days = Math.round(hours / 24);
return $_('forms.list.daysAgo', {
default: 'vor {n} Tagen',
values: { n: days },
});
}
</script>
<div class="forms-shell">
<header class="forms-header">
<h1>{$_('forms.app.title', { default: 'Formulare' })}</h1>
<p class="tagline">
{$_('forms.app.tagline', {
default: 'Eigene Formulare bauen und Antworten sammeln.',
})}
</p>
</header>
<div class="quick-create">
<input
type="text"
bind:value={newTitle}
onkeydown={handleQuickCreate}
placeholder={$_('forms.quickAdd.placeholder', {
default: 'Neues Formular ... (Enter)',
})}
aria-label={$_('forms.quickAdd.ariaLabel', { default: 'Formular-Titel' })}
/>
</div>
<div class="search-row">
<input
type="search"
class="search"
bind:value={searchQuery}
placeholder={$_('forms.list.searchPlaceholder', { default: 'Formulare durchsuchen ...' })}
/>
</div>
{#if filtered.length === 0}
{#if hasActiveSceneScope()}
<ScopeEmptyState label={$_('forms.app.title', { default: 'Formulare' })} />
{:else if forms.length === 0}
<div class="empty-state">
<p class="empty-title">
{$_('forms.list.emptyAll', { default: 'Noch keine Formulare angelegt.' })}
</p>
<p class="empty-hint">
{$_('forms.list.emptyHint', {
default: 'Tipp: Tippe oben einen Titel und drücke Enter.',
})}
</p>
</div>
{:else}
<div class="empty-state">
<p class="empty-title">
{$_('forms.list.emptySearch', { default: 'Keine Treffer für deine Suche.' })}
</p>
</div>
{/if}
{:else}
<ul class="form-list">
{#each filtered as form (form.id)}
<button
type="button"
class="form-card"
onclick={() => openForm(form)}
aria-label={form.title}
>
<span class="status-pill status-{form.status}">
{FORM_STATUS_LABELS[form.status].de}
</span>
<span class="title">{form.title}</span>
{#if form.description}
<span class="description">{form.description}</span>
{/if}
<span class="meta">
<span class="field-count">
{$_('forms.list.fieldCount', {
default: '{n} Felder',
values: { n: form.fields.length },
})}
</span>
<span class="response-count">
{$_('forms.list.responseCount', {
default: '{n} Antworten',
values: { n: form.responseCount },
})}
</span>
<span class="updated">{relativeTime(form.updatedAt)}</span>
</span>
</button>
{/each}
</ul>
{/if}
</div>
<style>
.forms-shell {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-width: 880px;
margin: 0 auto;
}
.forms-header h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.tagline {
color: rgb(255 255 255 / 0.6);
font-size: 0.875rem;
margin: 0.25rem 0 0;
}
.quick-create input,
.search {
width: 100%;
padding: 0.625rem 0.875rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.5rem;
color: inherit;
font-size: 0.9375rem;
}
.quick-create input:focus,
.search:focus {
outline: none;
border-color: rgb(255 255 255 / 0.2);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: rgb(255 255 255 / 0.5);
}
.empty-title {
font-size: 0.9375rem;
margin: 0 0 0.5rem;
}
.empty-hint {
font-size: 0.8125rem;
color: rgb(255 255 255 / 0.4);
margin: 0;
}
.form-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-card {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto auto;
grid-template-areas: 'pill title' '. description' '. meta';
gap: 0.25rem 0.75rem;
padding: 0.875rem 1rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.06);
border-radius: 0.5rem;
text-align: left;
color: inherit;
cursor: pointer;
}
.form-card:hover {
background: rgb(255 255 255 / 0.05);
border-color: rgb(255 255 255 / 0.12);
}
.status-pill {
grid-area: pill;
align-self: start;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-draft {
background: rgb(255 255 255 / 0.08);
color: rgb(255 255 255 / 0.7);
}
.status-published {
background: rgb(20 184 166 / 0.18);
color: rgb(94 234 212);
}
.status-closed {
background: rgb(255 255 255 / 0.06);
color: rgb(255 255 255 / 0.45);
}
.title {
grid-area: title;
font-weight: 500;
font-size: 0.9375rem;
}
.description {
grid-area: description;
color: rgb(255 255 255 / 0.55);
font-size: 0.8125rem;
}
.meta {
grid-area: meta;
display: flex;
gap: 0.75rem;
color: rgb(255 255 255 / 0.45);
font-size: 0.75rem;
}
</style>

View file

@ -0,0 +1,5 @@
import { db } from '$lib/data/database';
import type { LocalForm, LocalFormResponse } from './types';
export const formTable = db.table<LocalForm>('forms');
export const formResponseTable = db.table<LocalFormResponse>('formResponses');

View file

@ -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';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const formsModuleConfig: ModuleConfig = {
appId: 'forms',
tables: [{ name: 'forms' }, { name: 'formResponses' }],
};

View file

@ -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<LocalForm, string>('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<LocalForm, string>('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<LocalFormResponse, string>('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<LocalFormResponse, string>('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<FormStatus, number> = { 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);
});
}

View file

@ -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<FormSettings>;
}): Promise<Form> {
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<Pick<LocalForm, 'title' | 'description' | 'fields' | 'branching' | 'settings'>>
) {
const diff: Partial<LocalForm> = { ...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<LocalForm> = { fields };
await encryptRecord('forms', diff);
await formTable.update(id, diff);
},
async updateField(id: string, fieldId: string, patch: Partial<FormField>) {
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<LocalForm> = { 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<LocalForm> = { 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<LocalForm> = { fields: reordered };
await encryptRecord('forms', diff);
await formTable.update(id, diff);
},
};

View file

@ -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<string, AnswerValue>;
submitterEmail?: string | null;
submitterName?: string | null;
}): Promise<FormResponse> {
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() });
},
};

View file

@ -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<string, string>;
}
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<string, AnswerValue>;
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<string, AnswerValue>;
submitterEmail: string | null;
submitterName: string | null;
submitterMeta: SubmitterMeta | null;
status: ResponseStatus;
syncedTargets: SyncedTarget[];
createdAt: string;
updatedAt: string;
}
// ─── Constants ──────────────────────────────────────────────
export const FIELD_TYPE_LABELS: Record<FieldType, { de: string; en: string }> = {
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<FormStatus, { de: string; en: string }> = {
draft: { de: 'Entwurf', en: 'Draft' },
published: { de: 'Veröffentlicht', en: 'Published' },
closed: { de: 'Geschlossen', en: 'Closed' },
};
export const RESPONSE_STATUS_LABELS: Record<ResponseStatus, { de: string; en: string }> = {
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,
};

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/forms/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Forms - Mana</title>
</svelte:head>
<RoutePage appId="forms">
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
</RoutePage>