From 57b7a431479fa5ee421fae9abfb2128f1b85056d Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 29 Apr 2026 02:17:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(forms):=20M7b=20auto-sync=20zu=20events=20?= =?UTF-8?q?=E2=80=94=20RSVP-Pipeline=20f=C3=BCr=20Anmeldungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../web/src/lib/i18n/locales/forms/de.json | 10 ++ .../web/src/lib/i18n/locales/forms/en.json | 10 ++ .../web/src/lib/i18n/locales/forms/es.json | 10 ++ .../web/src/lib/i18n/locales/forms/fr.json | 10 ++ .../web/src/lib/i18n/locales/forms/it.json | 10 ++ .../forms/components/SettingsPanel.svelte | 124 ++++++++++++++---- .../lib/modules/forms/lib/auto-sync.spec.ts | 52 +++++++- .../src/lib/modules/forms/lib/auto-sync.ts | 91 +++++++++++-- .../apps/web/src/lib/modules/forms/types.ts | 2 + 9 files changed, 285 insertions(+), 34 deletions(-) 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 70822f129..474fd1220 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 @@ -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})" 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 48d21ca47..e3a66ff88 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 @@ -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})" 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 97cae881f..d5a69f84f 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 @@ -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})" 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 9c27e9a33..fcdd38dca 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 @@ -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})" 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 67063af9d..4baa5b06d 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 @@ -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})" 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 2df5a049e..45b646049 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 @@ -5,6 +5,7 @@ --> @@ -205,12 +248,40 @@ {/each} - {#if target === 'contacts'} + {#if target === 'events'} + + {#if !targetId} +

+ {$_('forms.builder.autoSync.eventNeeded', { + default: 'Wähle das Event, zu dem die RSVPs angelegt werden sollen.', + })} +

+ {/if} + {/if} + + {#if target !== 'none' && (target !== 'events' || targetId)} {#if ANSWER_FIELDS.length === 0}

{$_('forms.builder.autoSync.needFields', { @@ -219,10 +290,15 @@

{:else}

- {$_('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.', + })}

{#each ANSWER_FIELDS as f (f.id)} @@ -231,18 +307,20 @@
{/each} 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 index fae5dc9bb..8b2f5a3c2 100644 --- 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 @@ -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' }); + }); +}); 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 index 3bbba5098..9b640f84a 100644 --- 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 @@ -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, + mapping: Record +): { + 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, + cfg: AutoSyncConfig, answers: Record ): Promise { - 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`); } } diff --git a/apps/mana/apps/web/src/lib/modules/forms/types.ts b/apps/mana/apps/web/src/lib/modules/forms/types.ts index 04c975788..489b92ca2 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/types.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/types.ts @@ -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; }