feat(forms): M7a auto-sync zu contacts — der Mana-Differentiator

Bei einer neuen Form-Antwort entsteht automatisch ein Kontakt im
contacts-Modul (docs/plans/forms-module.md M7 — Teil 1):

- lib/auto-sync.ts:
  - buildContactFromAnswers (pure): Mapping form-field-id →
    contact-key (firstName/lastName/email/phone/...). Special target
    `name` splittet auf erstes Whitespace in firstName + lastName.
  - applyAutoSync (per Response): idempotent via syncedTargets-Check,
    schreibt nach Erfolg `{target, recordId}` ans Response-Row.
  - runAutoSyncSweep: scant alle Forms mit autoSync, dekrypt-aware
    (vault-locked = no-op), filtert pre-decrypt auf nicht-bereits-
    synced Responses für günstigen Skip. Per-Response-Errors werden
    geloggt aber blockieren den Rest nicht.
  - dispatchTarget für 'events' / 'feedback' / 'library' /
    'space_member' wirft "M7b not yet" — Surface ist da, UI filtert.
- lib/auto-sync.spec.ts: 6/6 Vitest-Cases.
- SettingsPanel: target-Dropdown ('Nichts' / 'Kontakt') + bei contacts
  Mapping-Grid über alle Antwortfelder mit dropdown der 15 contact-
  keys (name als auto-split, sonst firstName/lastName/email/phone/
  mobile/company/jobTitle/street/city/postalCode/country/birthday/
  website/notes).
- BuilderView reicht items-Field-Liste an SettingsPanel weiter.
- ResponsesView triggert runAutoSyncSweep on-mount + bei Response-
  Liste-Änderung. Bei synced > 0: Toast "{n} automatisch
  synchronisiert" 4 Sek lang.
- 8 neue i18n-Keys × 5 Locales (forms.builder.autoSync.*).

Total Forms-Tests: 22/22 (5 csv + 11 branching + 6 auto-sync).
svelte-check 0 errors. i18n-parity 6407 keys.

Future M7b: events (RSVP), feedback, library, space_member.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-29 01:07:22 +02:00
parent 59373c0d57
commit 7f805d9da2
11 changed files with 548 additions and 4 deletions

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -1,19 +1,113 @@
<!--
SettingsPanel — form-level settings (submit button, success message,
email-required, multiple-submissions). The visibility/share controls
land in M4 alongside the @mana/shared-privacy wiring.
email-required, multiple-submissions, auto-sync). Visibility/share
controls leben in BuilderView, das @mana/shared-privacy direkt nutzt.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { FormSettings } from '../types';
import type { AutoSyncTarget, FormField, FormSettings } from '../types';
let {
settings,
fields,
onchange,
}: {
settings: FormSettings;
fields: FormField[];
onchange: (patch: Partial<FormSettings>) => void;
} = $props();
// v1: nur 'contacts' implementiert (M7a). Andere Targets sind
// strukturell vorgesehen, aber die dispatchTarget-Branch wirft —
// wir filtern sie aus dem UI raus, bis M7b sie scharfschaltet.
const SUPPORTED_TARGETS: AutoSyncTarget[] = ['contacts'];
const CONTACT_KEYS = [
'name',
'firstName',
'lastName',
'email',
'phone',
'mobile',
'company',
'jobTitle',
'street',
'city',
'postalCode',
'country',
'birthday',
'website',
'notes',
] as const;
type ContactKey = (typeof CONTACT_KEYS)[number];
function contactKeyLabel(key: ContactKey): string {
switch (key) {
case 'name':
return $_('forms.builder.autoSync.contactKey.name', {
default: 'Vor- + Nachname (auto split)',
});
case 'firstName':
return 'Vorname';
case 'lastName':
return 'Nachname';
case 'email':
return 'E-Mail';
case 'phone':
return 'Telefon (Festnetz)';
case 'mobile':
return 'Mobil';
case 'company':
return 'Firma';
case 'jobTitle':
return 'Rolle / Position';
case 'street':
return 'Straße';
case 'city':
return 'Ort';
case 'postalCode':
return 'PLZ';
case 'country':
return 'Land';
case 'birthday':
return 'Geburtstag';
case 'website':
return 'Website';
case 'notes':
return 'Notiz';
}
}
const ANSWER_FIELDS = $derived(
fields.filter((f) => f.type !== 'section' && f.type !== 'consent')
);
const target = $derived<AutoSyncTarget | 'none'>(settings.autoSync?.target ?? 'none');
const mapping = $derived(settings.autoSync?.mapping ?? {});
function setTarget(next: AutoSyncTarget | 'none') {
if (next === 'none') {
onchange({ autoSync: undefined });
} else {
onchange({
autoSync: {
target: next,
mapping: settings.autoSync?.mapping ?? {},
},
});
}
}
function setMappingFor(fieldId: string, contactKey: ContactKey | '') {
const next = { ...mapping };
if (!contactKey) {
delete next[fieldId];
} else {
next[fieldId] = contactKey;
}
const t = settings.autoSync?.target ?? 'contacts';
onchange({ autoSync: { target: t, mapping: next } });
}
</script>
<div class="settings-panel">
@ -92,6 +186,70 @@
})}
</span>
</label>
<div class="auto-sync-block">
<p class="block-title">
{$_('forms.builder.autoSync.title', { default: 'Auto-Sync — bei Antwort erzeugen' })}
</p>
<select
class="target-select"
value={target}
onchange={(e) =>
setTarget((e.currentTarget as HTMLSelectElement).value as AutoSyncTarget | 'none')}
>
<option value="none">
{$_('forms.builder.autoSync.targetNone', { default: 'Nichts' })}
</option>
{#each SUPPORTED_TARGETS as t}
<option value={t}>
{t === 'contacts'
? $_('forms.builder.autoSync.targetContacts', { default: 'Kontakt' })
: t}
</option>
{/each}
</select>
{#if target === 'contacts'}
{#if ANSWER_FIELDS.length === 0}
<p class="hint">
{$_('forms.builder.autoSync.needFields', {
default: 'Lege mindestens ein Antwortfeld an, um Mapping zu konfigurieren.',
})}
</p>
{:else}
<p class="hint">
{$_('forms.builder.autoSync.contactsHint', {
default:
'Wähle für jedes Form-Feld, welches Kontakt-Feld es füllen soll. Leerlassen = ignorieren.',
})}
</p>
<div class="mapping-grid">
{#each ANSWER_FIELDS as f (f.id)}
<div class="mapping-row">
<span class="form-field-label" title={f.label}>{f.label}</span>
<select
class="contact-key-select"
value={mapping[f.id] ?? ''}
onchange={(e) =>
setMappingFor(
f.id,
(e.currentTarget as HTMLSelectElement).value as ContactKey | ''
)}
>
<option value="">
{$_('forms.builder.autoSync.ignore', { default: 'Ignorieren' })}
</option>
{#each CONTACT_KEYS as ck}
<option value={ck}>{contactKeyLabel(ck)}</option>
{/each}
</select>
</div>
{/each}
</div>
{/if}
{/if}
</div>
</div>
<style>
@ -144,4 +302,68 @@
color: rgb(255 255 255 / 0.8);
cursor: pointer;
}
.auto-sync-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.625rem;
border-top: 1px solid rgb(255 255 255 / 0.06);
}
.block-title {
margin: 0 0 0.25rem;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgb(255 255 255 / 0.45);
}
.target-select {
max-width: 240px;
padding: 0.375rem 0.625rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: inherit;
font-size: 0.8125rem;
}
.hint {
margin: 0;
font-size: 0.75rem;
color: rgb(255 255 255 / 0.5);
}
.mapping-grid {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.mapping-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
align-items: center;
}
.form-field-label {
font-size: 0.8125rem;
color: rgb(255 255 255 / 0.75);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-key-select {
min-width: 200px;
padding: 0.25rem 0.5rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.25rem;
color: inherit;
font-size: 0.8125rem;
}
</style>

View file

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

View file

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

View file

@ -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<string, AnswerValue>,
mapping: Record<string, string>
): Record<string, unknown> {
const contact: Record<string, unknown> = {};
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<string, string>,
answers: Record<string, AnswerValue>
): Promise<string | null> {
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 };
}

View file

@ -296,7 +296,7 @@
</section>
<section class="settings-section">
<SettingsPanel settings={entry.settings} onchange={patchSettings} />
<SettingsPanel settings={entry.settings} fields={items} onchange={patchSettings} />
</section>
</div>

View file

@ -7,8 +7,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { onMount } from 'svelte';
import { useFormResponses } from '../queries';
import { downloadResponsesCsv } from '../lib/csv';
import { runAutoSyncSweep } from '../lib/auto-sync';
import { RESPONSE_STATUS_LABELS } from '../types';
import type { Form, FormResponse, ResponseStatus } from '../types';
import ResponseDetailModal from '../components/ResponseDetailModal.svelte';
@ -18,6 +20,35 @@
const responses$ = useFormResponses(form.id);
const responses = $derived(responses$.value);
let autoSyncSummary = $state<string | null>(null);
// Auto-sync sweep on mount + every time the response list updates.
// Idempotent — runs only on responses without syncedTargets entry
// for the configured target. See lib/auto-sync.ts.
onMount(() => {
void triggerAutoSync();
});
$effect(() => {
// Recompute on response-list change. The sweep itself is a
// no-op when nothing is unsynced.
void responses;
void triggerAutoSync();
});
async function triggerAutoSync() {
if (!form.settings.autoSync?.target) return;
try {
const result = await runAutoSyncSweep();
if (result.synced > 0) {
autoSyncSummary = `${result.synced} Antwort(en) automatisch synchronisiert.`;
setTimeout(() => (autoSyncSummary = null), 4000);
}
} catch (err) {
autoSyncSummary = `Auto-Sync-Fehler: ${(err as Error).message}`;
}
}
type FilterTab = 'all' | ResponseStatus;
let activeTab = $state<FilterTab>('all');
@ -87,6 +118,10 @@
</button>
</header>
{#if autoSyncSummary}
<p class="auto-sync-toast">{autoSyncSummary}</p>
{/if}
<nav class="tabs" role="tablist">
{#each [['all', counts.all], ['new', counts.new], ['reviewed', counts.reviewed], ['archived', counts.archived], ['spam', counts.spam]] as const as [tab, count]}
<button
@ -226,6 +261,16 @@
cursor: not-allowed;
}
.auto-sync-toast {
margin: 0;
padding: 0.5rem 0.75rem;
background: rgb(20 184 166 / 0.14);
border: 1px solid rgb(20 184 166 / 0.3);
border-radius: 0.375rem;
color: rgb(94 234 212);
font-size: 0.8125rem;
}
.tabs {
display: flex;
gap: 0.25rem;