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