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
index 1d7ba0c7d..70822f129 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
@@ -75,6 +75,17 @@
"title": "Sichtbarkeit & Teilen",
"publishHint": "Setze den Status auf \"Veröffentlicht\", um zu teilen."
},
+ "autoSync": {
+ "title": "Auto-Sync — bei Antwort erzeugen",
+ "targetNone": "Nichts",
+ "targetContacts": "Kontakt",
+ "contactsHint": "Wähle für jedes Form-Feld, welches Kontakt-Feld es füllen soll. Leerlassen = ignorieren.",
+ "needFields": "Lege mindestens ein Antwortfeld an, um Mapping zu konfigurieren.",
+ "ignore": "Ignorieren",
+ "contactKey": {
+ "name": "Vor- + Nachname (auto split)"
+ }
+ },
"viewResponses": "Antworten ({n})"
},
"responses": {
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
index 806c1514c..48d21ca47 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
@@ -75,6 +75,17 @@
"title": "Visibility & Sharing",
"publishHint": "Set the status to \"Published\" to share."
},
+ "autoSync": {
+ "title": "Auto-sync — create on submit",
+ "targetNone": "None",
+ "targetContacts": "Contact",
+ "contactsHint": "For each form field, pick which contact field it should fill. Leave empty to ignore.",
+ "needFields": "Add at least one answer field to configure mapping.",
+ "ignore": "Ignore",
+ "contactKey": {
+ "name": "First + last name (auto split)"
+ }
+ },
"viewResponses": "Responses ({n})"
},
"responses": {
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
index f88f37d75..97cae881f 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
@@ -75,6 +75,17 @@
"title": "Visibilidad y compartir",
"publishHint": "Pon el estado en \"Publicado\" para compartir."
},
+ "autoSync": {
+ "title": "Auto-sync — crear al recibir respuesta",
+ "targetNone": "Nada",
+ "targetContacts": "Contacto",
+ "contactsHint": "Para cada campo del formulario, elige qué campo del contacto debe rellenar. Deja vacío para ignorar.",
+ "needFields": "Añade al menos un campo de respuesta para configurar el mapeo.",
+ "ignore": "Ignorar",
+ "contactKey": {
+ "name": "Nombre + apellido (auto split)"
+ }
+ },
"viewResponses": "Respuestas ({n})"
},
"responses": {
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
index 8ba83876a..9c27e9a33 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
@@ -75,6 +75,17 @@
"title": "Visibilité et partage",
"publishHint": "Mets le statut sur \"Publié\" pour partager."
},
+ "autoSync": {
+ "title": "Auto-sync — créer à la soumission",
+ "targetNone": "Rien",
+ "targetContacts": "Contact",
+ "contactsHint": "Pour chaque champ du formulaire, choisis quel champ de contact remplir. Laisse vide pour ignorer.",
+ "needFields": "Ajoute au moins un champ de réponse pour configurer le mapping.",
+ "ignore": "Ignorer",
+ "contactKey": {
+ "name": "Prénom + nom (auto split)"
+ }
+ },
"viewResponses": "Réponses ({n})"
},
"responses": {
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
index 78a6b352e..67063af9d 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
@@ -75,6 +75,17 @@
"title": "Visibilità e condivisione",
"publishHint": "Imposta lo stato su \"Pubblicato\" per condividere."
},
+ "autoSync": {
+ "title": "Auto-sync — crea al ricevere risposta",
+ "targetNone": "Nessuno",
+ "targetContacts": "Contatto",
+ "contactsHint": "Per ogni campo del modulo, scegli quale campo del contatto deve riempire. Lascia vuoto per ignorare.",
+ "needFields": "Aggiungi almeno un campo di risposta per configurare il mapping.",
+ "ignore": "Ignora",
+ "contactKey": {
+ "name": "Nome + cognome (auto split)"
+ }
+ },
"viewResponses": "Risposte ({n})"
},
"responses": {
diff --git a/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte b/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte
index 19cf1bfd6..2df5a049e 100644
--- a/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte
+++ b/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte
@@ -1,19 +1,113 @@
@@ -92,6 +186,70 @@
})}
+
+
+
+ {$_('forms.builder.autoSync.title', { default: 'Auto-Sync — bei Antwort erzeugen' })}
+
+
+
+
+ {#if target === 'contacts'}
+ {#if ANSWER_FIELDS.length === 0}
+
+ {$_('forms.builder.autoSync.needFields', {
+ default: 'Lege mindestens ein Antwortfeld an, um Mapping zu konfigurieren.',
+ })}
+
+ {:else}
+
+ {$_('forms.builder.autoSync.contactsHint', {
+ default:
+ 'Wähle für jedes Form-Feld, welches Kontakt-Feld es füllen soll. Leerlassen = ignorieren.',
+ })}
+
+
+ {#each ANSWER_FIELDS as f (f.id)}
+
+ {f.label}
+
+
+ {/each}
+
+ {/if}
+ {/if}
+
diff --git a/apps/mana/apps/web/src/lib/modules/forms/index.ts b/apps/mana/apps/web/src/lib/modules/forms/index.ts
index adcc3062a..46c138cf6 100644
--- a/apps/mana/apps/web/src/lib/modules/forms/index.ts
+++ b/apps/mana/apps/web/src/lib/modules/forms/index.ts
@@ -20,6 +20,7 @@ export { formTable, formResponseTable } from './collections';
export { makeDefaultField } from './lib/field-defaults';
export { resolveVisibleFields } from './lib/branching';
export { buildResponsesCsv, downloadResponsesCsv } from './lib/csv';
+export { buildContactFromAnswers, applyAutoSync, runAutoSyncSweep } from './lib/auto-sync';
// ─── Types ───────────────────────────────────────────────
export {
diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/auto-sync.spec.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/auto-sync.spec.ts
new file mode 100644
index 000000000..fae5dc9bb
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/lib/auto-sync.spec.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest';
+import { buildContactFromAnswers } from './auto-sync';
+
+describe('buildContactFromAnswers', () => {
+ it('maps form-fields to contact-fields directly', () => {
+ const result = buildContactFromAnswers(
+ { 'f-name': 'Anna Mustermann', 'f-email': 'anna@example.com', 'f-phone': '+49 30 12345' },
+ { 'f-name': 'firstName', 'f-email': 'email', 'f-phone': 'phone' }
+ );
+ expect(result).toEqual({
+ firstName: 'Anna Mustermann',
+ email: 'anna@example.com',
+ phone: '+49 30 12345',
+ });
+ });
+
+ it('special-cases the synthetic "name" target by splitting on first whitespace', () => {
+ const result = buildContactFromAnswers(
+ { 'f-name': 'Anna Mustermann von Beispiel' },
+ { 'f-name': 'name' }
+ );
+ expect(result).toEqual({ firstName: 'Anna', lastName: 'Mustermann von Beispiel' });
+ });
+
+ it('puts a single-word name into firstName and leaves lastName unset', () => {
+ const result = buildContactFromAnswers({ 'f-name': 'Madonna' }, { 'f-name': 'name' });
+ expect(result).toEqual({ firstName: 'Madonna' });
+ });
+
+ it('skips empty / null / undefined answers', () => {
+ const result = buildContactFromAnswers(
+ { 'f-name': '', 'f-email': null, 'f-phone': undefined as unknown as string },
+ { 'f-name': 'firstName', 'f-email': 'email', 'f-phone': 'phone' }
+ );
+ expect(result).toEqual({});
+ });
+
+ it('coerces non-string answers to string before mapping', () => {
+ const result = buildContactFromAnswers(
+ { 'f-num': 42 as unknown as string },
+ { 'f-num': 'phone' }
+ );
+ expect(result).toEqual({ phone: '42' });
+ });
+
+ it('ignores form-fields that have no mapping', () => {
+ const result = buildContactFromAnswers(
+ { 'f-name': 'Anna', 'f-extra': 'something' },
+ { 'f-name': 'firstName' }
+ );
+ expect(result).toEqual({ firstName: 'Anna' });
+ });
+});
diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/auto-sync.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/auto-sync.ts
new file mode 100644
index 000000000..3bbba5098
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/lib/auto-sync.ts
@@ -0,0 +1,168 @@
+/**
+ * Forms — auto-sync to other modules (M7a).
+ *
+ * Bei einer neuen Form-Antwort kann der Owner konfigurieren, dass die
+ * Antwort automatisch in ein anderes Modul fliesst. v1 unterstuetzt
+ * `contacts` — typischer Vereins-Anmeldungs-Flow:
+ * form-field-X (Name) → contact.firstName + lastName
+ * form-field-Y (Email) → contact.email
+ * form-field-Z (Telefon) → contact.phone
+ *
+ * Der Hook ist owner-side: laeuft, wenn der Client eine neue Antwort
+ * sieht (Pull oder lokaler Insert) UND die Antwort noch keinen
+ * `syncedTargets`-Eintrag fuer das konfigurierte Target hat. Der
+ * Server kann das nicht — er hat keinen master-key fuer encrypted
+ * tables wie `contacts`. Owner-side ist die richtige Stelle.
+ *
+ * Idempotenz: jede Anwendung schreibt einen `syncedTargets`-Eintrag
+ * mit { target, recordId }. Beim naechsten Scan wird die Antwort
+ * uebersprungen, weil der Eintrag bereits drin ist.
+ *
+ * Plan: docs/plans/forms-module.md M7.
+ */
+
+import { contactsStore } from '$lib/modules/contacts/stores/contacts.svelte';
+import { decryptRecords, isVaultUnlocked } from '$lib/data/crypto';
+import { formResponseTable, formTable } from '../collections';
+import { toForm, toFormResponse } from '../queries';
+import type { AnswerValue, AutoSyncTarget, Form, FormResponse } from '../types';
+
+/**
+ * Build a contact-record patch from a form response, given the
+ * field-mapping configured by the form owner. Pure — no Dexie writes.
+ *
+ * The mapping shape is:
+ * { [formFieldId]: 'firstName' | 'lastName' | 'email' | 'phone' | ... }
+ *
+ * Special case: a single mapping target `'name'` splits the answer
+ * into firstName + lastName by the first whitespace. Useful when the
+ * form has a single "Name"-field.
+ */
+export function buildContactFromAnswers(
+ answers: Record,
+ mapping: Record
+): Record {
+ const contact: Record = {};
+
+ for (const [fieldId, contactKey] of Object.entries(mapping)) {
+ const value = answers[fieldId];
+ if (value === null || value === undefined) continue;
+ const str = typeof value === 'string' ? value.trim() : String(value);
+ if (!str) continue;
+
+ if (contactKey === 'name') {
+ const parts = str.split(/\s+/);
+ contact.firstName = parts.shift() ?? str;
+ if (parts.length > 0) contact.lastName = parts.join(' ');
+ } else {
+ contact[contactKey] = str;
+ }
+ }
+
+ return contact;
+}
+
+/**
+ * Apply autoSync for a single response. Idempotent — checks
+ * `syncedTargets` first; returns early if already synced. Mutates
+ * the response row to record the new mapping.
+ *
+ * Throws if the form's `autoSync.target` is unsupported. Caller
+ * decides whether to swallow (best-effort sweep) or surface the error
+ * (interactive button click).
+ */
+export async function applyAutoSync(
+ form: Form,
+ response: FormResponse
+): Promise<{ synced: boolean; recordId?: string }> {
+ const cfg = form.settings.autoSync;
+ if (!cfg) return { synced: false };
+
+ const already = (response.syncedTargets ?? []).find((t) => t.target === cfg.target);
+ if (already) return { synced: false, recordId: already.recordId };
+
+ const recordId = await dispatchTarget(cfg.target, cfg.mapping, response.answers);
+ if (!recordId) return { synced: false };
+
+ const next = [...(response.syncedTargets ?? []), { target: cfg.target, recordId }];
+ await formResponseTable.update(response.id, { syncedTargets: next });
+ return { synced: true, recordId };
+}
+
+async function dispatchTarget(
+ target: AutoSyncTarget,
+ mapping: Record,
+ answers: Record
+): Promise {
+ switch (target) {
+ case 'contacts': {
+ const data = buildContactFromAnswers(answers, mapping);
+ // Need at least a name or email to create a contact — anything
+ // less leaks empty rows into /contacts.
+ if (!data.firstName && !data.lastName && !data.email) {
+ return null;
+ }
+ const contact = await contactsStore.createContact(data);
+ return contact?.id ?? null;
+ }
+ case 'events':
+ case 'feedback':
+ case 'library':
+ case 'space_member':
+ // Future M7b — surfaces left wired so the planner doesn't
+ // silently no-op when the user picks an unsupported target.
+ throw new Error(`autoSync target "${target}" is not yet implemented (M7b)`);
+ }
+}
+
+/**
+ * Sweep every response of every form with autoSync configured, applying
+ * any pending sync. Idempotent — already-synced responses are skipped
+ * via the syncedTargets check inside applyAutoSync.
+ *
+ * Vault-locked → no-op (decrypt would fail anyway). Per-response
+ * failures are caught + logged so one bad mapping doesn't block the
+ * rest of the queue.
+ */
+export async function runAutoSyncSweep(): Promise<{ scanned: number; synced: number }> {
+ if (!isVaultUnlocked()) return { scanned: 0, synced: 0 };
+
+ const rawForms = (await formTable.toArray()).filter((f) => !f.deletedAt);
+ if (rawForms.length === 0) return { scanned: 0, synced: 0 };
+
+ const decrypted = await decryptRecords('forms', rawForms);
+ const forms = decrypted.map(toForm).filter((f) => f.settings?.autoSync?.target);
+ if (forms.length === 0) return { scanned: 0, synced: 0 };
+
+ let scanned = 0;
+ let synced = 0;
+
+ for (const form of forms) {
+ const rawResponses = (await formResponseTable.where('formId').equals(form.id).toArray()).filter(
+ (r) => !r.deletedAt
+ );
+
+ // Skip responses already synced for this target (cheap check on
+ // plaintext field) before paying the decrypt cost.
+ const target = form.settings.autoSync!.target;
+ const candidates = rawResponses.filter(
+ (r) => !(r.syncedTargets ?? []).some((t) => t.target === target)
+ );
+ if (candidates.length === 0) continue;
+
+ const decryptedResponses = await decryptRecords('formResponses', candidates);
+ const responses = decryptedResponses.map(toFormResponse);
+
+ for (const r of responses) {
+ scanned += 1;
+ try {
+ const result = await applyAutoSync(form, r);
+ if (result.synced) synced += 1;
+ } catch (err) {
+ console.warn(`[forms-autosync] failed for response ${r.id}: ${(err as Error).message}`);
+ }
+ }
+ }
+
+ return { scanned, synced };
+}
diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
index 47c2b8324..6ccaea12a 100644
--- a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
@@ -296,7 +296,7 @@
diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte
index 0082f10dc..0322af0f7 100644
--- a/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte
@@ -7,8 +7,10 @@