feat(forms): M7b auto-sync zu events — RSVP-Pipeline für Anmeldungen

Erweiterung von M7a um events als zweites Auto-Sync-Target
(docs/plans/forms-module.md M7 — Teil 2):

- AutoSyncConfig erweitert mit optionalem `targetId` — speichert die
  eventId, zu der RSVPs angelegt werden sollen.
- lib/auto-sync.ts:
  - buildEventGuestFromAnswers (pure): mapping form-field-id →
    guest-key (name/email/phone/note/plusOnes). plusOnes wird zu
    non-negativem Integer gecoerct, Negative + non-numeric werden
    silently gedroppt.
  - dispatchTarget('events'): wirft wenn targetId fehlt; ruft
    eventGuestsStore.addGuest mit rsvpStatus='yes' (Form-Submit
    impliziert Zusage). Mindest-Voraussetzung: name muss gesetzt
    sein, sonst null (verhindert leere Gast-Zeilen).
  - feedback / library / space_member bleiben strukturell mit
    "noch nicht implementiert"-throw — feedback ist eigene Domain
    (kein Dexie), library + space_member brauchen mehr Architektur.
- lib/auto-sync.spec.ts: 4 neue Tests (direct mapping, plusOnes
  parsing, empty/null skip, unknown-key drop). Total Forms-Tests
  jetzt 26/26.
- SettingsPanel: SUPPORTED_TARGETS auf [contacts, events] erweitert.
  Bei target='events': event-picker dropdown (nutzt useAllEvents),
  Hint wenn kein Event gewählt, Mapping-Grid mit GuestKey-Optionen
  (name, email, phone, note, Begleitpersonen). Target-Switch löscht
  altes mapping (verschiedene Targets, verschiedene Keys).
- 13 neue i18n-Keys × 5 Locales (autoSync.targetEvents,
  eventsHint, eventPicker*, eventNeeded, guestKey.*).

Use-Case: Vereins-Sommerfest. Form mit "Wie heißt du? / Email /
Bringst du jemanden mit?" → autoSync zu Event "Sommerfest 2026".
Submit erzeugt automatisch Gast mit RSVP=yes. Kein manuelles
Übertragen mehr nötig — direkter Pipeline-Vorteil gegenüber
Typeform/Tally.

svelte-check 0 errors. i18n-parity 6415 keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-29 02:17:37 +02:00
parent e8774fc233
commit 57b7a43147
9 changed files with 285 additions and 34 deletions

View file

@ -79,11 +79,21 @@
"title": "Auto-Sync — bei Antwort erzeugen",
"targetNone": "Nichts",
"targetContacts": "Kontakt",
"targetEvents": "Event-RSVP",
"contactsHint": "Wähle für jedes Form-Feld, welches Kontakt-Feld es füllen soll. Leerlassen = ignorieren.",
"eventsHint": "Wähle für jedes Form-Feld, welches Gast-Feld es füllen soll. RSVPs werden auf \"Zusage\" gesetzt.",
"eventPicker": "Welches Event?",
"eventPickerNone": "Bitte wählen ...",
"eventNeeded": "Wähle das Event, zu dem die RSVPs angelegt werden sollen.",
"needFields": "Lege mindestens ein Antwortfeld an, um Mapping zu konfigurieren.",
"ignore": "Ignorieren",
"contactKey": {
"name": "Vor- + Nachname (auto split)"
},
"guestKey": {
"name": "Name",
"note": "Notiz",
"plusOnes": "Begleitpersonen"
}
},
"viewResponses": "Antworten ({n})"

View file

@ -79,11 +79,21 @@
"title": "Auto-sync — create on submit",
"targetNone": "None",
"targetContacts": "Contact",
"targetEvents": "Event RSVP",
"contactsHint": "For each form field, pick which contact field it should fill. Leave empty to ignore.",
"eventsHint": "For each form field, pick which guest field it should fill. RSVPs are set to \"attending\".",
"eventPicker": "Which event?",
"eventPickerNone": "Please pick ...",
"eventNeeded": "Pick the event the RSVPs should be created for.",
"needFields": "Add at least one answer field to configure mapping.",
"ignore": "Ignore",
"contactKey": {
"name": "First + last name (auto split)"
},
"guestKey": {
"name": "Name",
"note": "Note",
"plusOnes": "Plus-ones"
}
},
"viewResponses": "Responses ({n})"

View file

@ -79,11 +79,21 @@
"title": "Auto-sync — crear al recibir respuesta",
"targetNone": "Nada",
"targetContacts": "Contacto",
"targetEvents": "RSVP de evento",
"contactsHint": "Para cada campo del formulario, elige qué campo del contacto debe rellenar. Deja vacío para ignorar.",
"eventsHint": "Para cada campo del formulario, elige qué campo del invitado debe rellenar. Los RSVPs se ponen en \"asistiré\".",
"eventPicker": "¿Qué evento?",
"eventPickerNone": "Selecciona ...",
"eventNeeded": "Elige el evento al que se van a crear los RSVPs.",
"needFields": "Añade al menos un campo de respuesta para configurar el mapeo.",
"ignore": "Ignorar",
"contactKey": {
"name": "Nombre + apellido (auto split)"
},
"guestKey": {
"name": "Nombre",
"note": "Nota",
"plusOnes": "Acompañantes"
}
},
"viewResponses": "Respuestas ({n})"

View file

@ -79,11 +79,21 @@
"title": "Auto-sync — créer à la soumission",
"targetNone": "Rien",
"targetContacts": "Contact",
"targetEvents": "RSVP événement",
"contactsHint": "Pour chaque champ du formulaire, choisis quel champ de contact remplir. Laisse vide pour ignorer.",
"eventsHint": "Pour chaque champ du formulaire, choisis quel champ d'invité remplir. Les RSVPs sont mis sur \"présent\".",
"eventPicker": "Quel événement ?",
"eventPickerNone": "Choisir ...",
"eventNeeded": "Choisis l'événement pour lequel les RSVPs seront créés.",
"needFields": "Ajoute au moins un champ de réponse pour configurer le mapping.",
"ignore": "Ignorer",
"contactKey": {
"name": "Prénom + nom (auto split)"
},
"guestKey": {
"name": "Nom",
"note": "Note",
"plusOnes": "Accompagnants"
}
},
"viewResponses": "Réponses ({n})"

View file

@ -79,11 +79,21 @@
"title": "Auto-sync — crea al ricevere risposta",
"targetNone": "Nessuno",
"targetContacts": "Contatto",
"targetEvents": "RSVP evento",
"contactsHint": "Per ogni campo del modulo, scegli quale campo del contatto deve riempire. Lascia vuoto per ignorare.",
"eventsHint": "Per ogni campo del modulo, scegli quale campo dell'ospite deve riempire. Gli RSVP vengono impostati su \"presente\".",
"eventPicker": "Quale evento?",
"eventPickerNone": "Seleziona ...",
"eventNeeded": "Scegli l'evento per cui creare gli RSVP.",
"needFields": "Aggiungi almeno un campo di risposta per configurare il mapping.",
"ignore": "Ignora",
"contactKey": {
"name": "Nome + cognome (auto split)"
},
"guestKey": {
"name": "Nome",
"note": "Nota",
"plusOnes": "Accompagnatori"
}
},
"viewResponses": "Risposte ({n})"

View file

@ -5,6 +5,7 @@
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { useAllEvents } from '$lib/modules/events/queries';
import type { AutoSyncTarget, FormField, FormSettings } from '../types';
let {
@ -17,10 +18,13 @@
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'];
// M7a: contacts. M7b: events (RSVP). Andere Targets (feedback,
// library, space_member) bleiben strukturell — dispatchTarget wirft,
// UI filtert sie aus, bis sie scharfgeschaltet werden.
const SUPPORTED_TARGETS: AutoSyncTarget[] = ['contacts', 'events'];
const events$ = useAllEvents();
const events = $derived(events$.value);
const CONTACT_KEYS = [
'name',
@ -78,35 +82,74 @@
}
}
const GUEST_KEYS = ['name', 'email', 'phone', 'note', 'plusOnes'] as const;
type GuestKey = (typeof GUEST_KEYS)[number];
function guestKeyLabel(key: GuestKey): string {
switch (key) {
case 'name':
return $_('forms.builder.autoSync.guestKey.name', { default: 'Name' });
case 'email':
return 'E-Mail';
case 'phone':
return 'Telefon';
case 'note':
return $_('forms.builder.autoSync.guestKey.note', { default: 'Notiz' });
case 'plusOnes':
return $_('forms.builder.autoSync.guestKey.plusOnes', { default: 'Begleitpersonen' });
}
}
const ANSWER_FIELDS = $derived(
fields.filter((f) => f.type !== 'section' && f.type !== 'consent')
);
const target = $derived<AutoSyncTarget | 'none'>(settings.autoSync?.target ?? 'none');
const targetId = $derived(settings.autoSync?.targetId ?? '');
const mapping = $derived(settings.autoSync?.mapping ?? {});
function setTarget(next: AutoSyncTarget | 'none') {
if (next === 'none') {
onchange({ autoSync: undefined });
} else {
// Switching target → drop mapping (different target, different
// allowed keys). targetId is only meaningful for events.
onchange({
autoSync: {
target: next,
mapping: settings.autoSync?.mapping ?? {},
mapping: {},
...(next === 'events' && targetId ? { targetId } : {}),
},
});
}
}
function setMappingFor(fieldId: string, contactKey: ContactKey | '') {
function setTargetId(eventId: string) {
onchange({
autoSync: {
target: 'events',
targetId: eventId || undefined,
mapping,
},
});
}
function setMappingFor(fieldId: string, key: string) {
const next = { ...mapping };
if (!contactKey) {
if (!key) {
delete next[fieldId];
} else {
next[fieldId] = contactKey;
next[fieldId] = key;
}
const t = settings.autoSync?.target ?? 'contacts';
onchange({ autoSync: { target: t, mapping: next } });
const tid = settings.autoSync?.targetId;
onchange({
autoSync: {
target: t,
mapping: next,
...(tid ? { targetId: tid } : {}),
},
});
}
</script>
@ -205,12 +248,40 @@
<option value={t}>
{t === 'contacts'
? $_('forms.builder.autoSync.targetContacts', { default: 'Kontakt' })
: t}
: t === 'events'
? $_('forms.builder.autoSync.targetEvents', { default: 'Event-RSVP' })
: t}
</option>
{/each}
</select>
{#if target === 'contacts'}
{#if target === 'events'}
<label class="setting-row">
<span class="setting-label">
{$_('forms.builder.autoSync.eventPicker', { default: 'Welches Event?' })}
</span>
<select
value={targetId}
onchange={(e) => setTargetId((e.currentTarget as HTMLSelectElement).value)}
>
<option value=""
>{$_('forms.builder.autoSync.eventPickerNone', { default: 'Bitte wählen ...' })}</option
>
{#each events as ev (ev.id)}
<option value={ev.id}>{ev.title}</option>
{/each}
</select>
</label>
{#if !targetId}
<p class="hint">
{$_('forms.builder.autoSync.eventNeeded', {
default: 'Wähle das Event, zu dem die RSVPs angelegt werden sollen.',
})}
</p>
{/if}
{/if}
{#if target !== 'none' && (target !== 'events' || targetId)}
{#if ANSWER_FIELDS.length === 0}
<p class="hint">
{$_('forms.builder.autoSync.needFields', {
@ -219,10 +290,15 @@
</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.',
})}
{target === 'events'
? $_('forms.builder.autoSync.eventsHint', {
default:
'Wähle für jedes Form-Feld, welches Gast-Feld es füllen soll. RSVPs werden auf "Zusage" gesetzt.',
})
: $_('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)}
@ -231,18 +307,20 @@
<select
class="contact-key-select"
value={mapping[f.id] ?? ''}
onchange={(e) =>
setMappingFor(
f.id,
(e.currentTarget as HTMLSelectElement).value as ContactKey | ''
)}
onchange={(e) => setMappingFor(f.id, (e.currentTarget as HTMLSelectElement).value)}
>
<option value="">
{$_('forms.builder.autoSync.ignore', { default: 'Ignorieren' })}
</option>
{#each CONTACT_KEYS as ck}
<option value={ck}>{contactKeyLabel(ck)}</option>
{/each}
{#if target === 'events'}
{#each GUEST_KEYS as gk}
<option value={gk}>{guestKeyLabel(gk)}</option>
{/each}
{:else}
{#each CONTACT_KEYS as ck}
<option value={ck}>{contactKeyLabel(ck)}</option>
{/each}
{/if}
</select>
</div>
{/each}

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildContactFromAnswers } from './auto-sync';
import { buildContactFromAnswers, buildEventGuestFromAnswers } from './auto-sync';
describe('buildContactFromAnswers', () => {
it('maps form-fields to contact-fields directly', () => {
@ -51,3 +51,53 @@ describe('buildContactFromAnswers', () => {
expect(result).toEqual({ firstName: 'Anna' });
});
});
describe('buildEventGuestFromAnswers', () => {
it('maps name/email/phone/note straight through', () => {
const result = buildEventGuestFromAnswers(
{
'f-name': 'Anna Mustermann',
'f-email': 'anna@example.com',
'f-phone': '+49 30 12345',
'f-note': 'Bringe Salat mit',
},
{ 'f-name': 'name', 'f-email': 'email', 'f-phone': 'phone', 'f-note': 'note' }
);
expect(result).toEqual({
name: 'Anna Mustermann',
email: 'anna@example.com',
phone: '+49 30 12345',
note: 'Bringe Salat mit',
});
});
it('parses plusOnes as a non-negative integer', () => {
expect(buildEventGuestFromAnswers({ 'f-plus': '2' }, { 'f-plus': 'plusOnes' })).toEqual({
plusOnes: 2,
});
expect(
buildEventGuestFromAnswers({ 'f-plus': '2.7' as unknown as string }, { 'f-plus': 'plusOnes' })
).toEqual({ plusOnes: 2 });
// Negative + non-numeric → drop silently
expect(buildEventGuestFromAnswers({ 'f-plus': '-1' }, { 'f-plus': 'plusOnes' })).toEqual({});
expect(buildEventGuestFromAnswers({ 'f-plus': 'abc' }, { 'f-plus': 'plusOnes' })).toEqual({});
});
it('skips empty / null / undefined answers', () => {
const result = buildEventGuestFromAnswers(
{ 'f-name': '', 'f-email': null },
{ 'f-name': 'name', 'f-email': 'email' }
);
expect(result).toEqual({});
});
it('ignores unknown contact-style keys (firstName/lastName)', () => {
// guest model has no firstName — only `name`. Mappings to non-
// guest keys are silently dropped.
const result = buildEventGuestFromAnswers(
{ 'f-fn': 'Anna', 'f-name': 'Anna Mustermann' },
{ 'f-fn': 'firstName', 'f-name': 'name' }
);
expect(result).toEqual({ name: 'Anna Mustermann' });
});
});

View file

@ -22,10 +22,11 @@
*/
import { contactsStore } from '$lib/modules/contacts/stores/contacts.svelte';
import { eventGuestsStore } from '$lib/modules/events/stores/guests.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';
import type { AnswerValue, AutoSyncConfig, Form, FormResponse } from '../types';
/**
* Build a contact-record patch from a form response, given the
@ -81,7 +82,7 @@ export async function applyAutoSync(
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);
const recordId = await dispatchTarget(cfg, response.answers);
if (!recordId) return { synced: false };
const next = [...(response.syncedTargets ?? []), { target: cfg.target, recordId }];
@ -89,14 +90,62 @@ export async function applyAutoSync(
return { synced: true, recordId };
}
/**
* Build an event-guest patch from a form response. Pure. The `name`
* key is the synthetic auto-split target (firstName + lastName joined
* with whitespace) guest records carry a single `name` field, so we
* collapse the contact-style split here.
*/
export function buildEventGuestFromAnswers(
answers: Record<string, AnswerValue>,
mapping: Record<string, string>
): {
name?: string;
email?: string;
phone?: string;
note?: string;
plusOnes?: number;
} {
const guest: { name?: string; email?: string; phone?: string; note?: string; plusOnes?: number } =
{};
for (const [fieldId, key] 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;
switch (key) {
case 'name':
guest.name = str;
break;
case 'email':
guest.email = str;
break;
case 'phone':
guest.phone = str;
break;
case 'note':
guest.note = str;
break;
case 'plusOnes': {
const n = Number(str);
if (Number.isFinite(n) && n >= 0) guest.plusOnes = Math.floor(n);
break;
}
}
}
return guest;
}
async function dispatchTarget(
target: AutoSyncTarget,
mapping: Record<string, string>,
cfg: AutoSyncConfig,
answers: Record<string, AnswerValue>
): Promise<string | null> {
switch (target) {
switch (cfg.target) {
case 'contacts': {
const data = buildContactFromAnswers(answers, mapping);
const data = buildContactFromAnswers(answers, cfg.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) {
@ -105,13 +154,35 @@ async function dispatchTarget(
const contact = await contactsStore.createContact(data);
return contact?.id ?? null;
}
case 'events':
case 'events': {
if (!cfg.targetId) {
throw new Error('autoSync.targetId (eventId) ist erforderlich für target=events');
}
const guest = buildEventGuestFromAnswers(answers, cfg.mapping);
// Need at least a name to create a guest entry. Without one
// the RSVP list collects empty rows.
if (!guest.name) return null;
const result = await eventGuestsStore.addGuest({
eventId: cfg.targetId,
name: guest.name,
email: guest.email ?? null,
phone: guest.phone ?? null,
note: guest.note ?? null,
plusOnes: guest.plusOnes ?? 0,
rsvpStatus: 'yes',
});
return result.success ? result.id : null;
}
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)`);
// Out of M7b scope. feedback ist zentraler Public-Hub (eigene
// Domain, keine Dexie), library + space_member brauchen mehr
// Architektur (target-id picker für library, invite-flow für
// space_member). Bleibt explicit "not yet" damit der UI-Filter
// im SettingsPanel keine option-Werte vergibt, die runtime
// brechen wuerden.
throw new Error(`autoSync target "${cfg.target}" ist noch nicht implementiert`);
}
}

View file

@ -72,6 +72,8 @@ export type AutoSyncTarget = 'contacts' | 'events' | 'feedback' | 'library' | 's
export interface AutoSyncConfig {
target: AutoSyncTarget;
/** Optional anchor — for `events` this is the eventId the response RSVPs to. */
targetId?: string;
mapping: Record<string, string>;
}