From 82dbfe6ee760e7c789a3b67c1b93d510f5078964 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 6 May 2026 16:27:40 +0200 Subject: [PATCH] feat(forms): M7c auto-sync zu library + space_member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schließt M7 ab: Form-Antworten erzeugen jetzt zusätzlich zu Kontakten (M7a) und Event-RSVPs (M7b) auch Library-Einträge und Space- Einladungen. feedback bleibt bewusst aus dem UI raus — Architektur-Mismatch. - types.ts: - AutoSyncConfig erweitert um optional `libraryKind` ('book'|'movie'|'series'|'comic') und `spaceMemberRole` ('member'|'admin', default 'member'). - Form domain-type bekommt `spaceId: string` (war intern auf LocalForm vorhanden, wird jetzt durch toForm exposed). Brauchen wir, weil space_member-Invite den organizationId der Form-Owner- Space schicken muss. - queries.ts toForm: spaceId aus LocalForm.spaceId mappen, Fallback ''. - lib/auto-sync.ts: - buildLibraryEntryFromAnswers (pure): mappt title / creators / year / review. creators-strings werden auf , ; \n gesplittet (multi-author-mapping). year bounds-checked 1900..2100. - buildSpaceInviteFromAnswers (pure): findet das erste Form-Feld mit mapping='email', validiert per Loose-Regex, gibt {email}-payload zurück. - dispatchTarget('library'): wirft wenn libraryKind fehlt; ruft libraryEntriesStore.createEntry mit kind+title+creators+year+ review. - dispatchTarget('space_member'): wirft wenn form.spaceId fehlt; POSTet an /api/auth/organization/invite-member über authFetch mit role aus cfg.spaceMemberRole. Returns invitation.id oder Fallback `invite:` (better-auth response-shape kann je nach Version variieren). - dispatchTarget('feedback') wirft jetzt mit klarem Kommentar: architektur-Mismatch — feedback ist zentraler Public-Hub, nicht per-Owner-Daten. UI filtert die Option raus. - applyAutoSync reicht `form` durch zu dispatchTarget (statt nur cfg/answers), damit Space-Invite die spaceId hat. - lib/auto-sync.spec.ts: 9 weitere Tests (4 library: title/creators/ year-bounds/empty, 5 space: extract/malformed/non-mapped/no-mapping/ non-string). Total Forms-Tests jetzt 70/70. - SettingsPanel: - SUPPORTED_TARGETS auf [contacts, events, library, space_member] erweitert. feedback erscheint NICHT — Type bleibt für Legacy- Daten erhalten, aber UI bietet ihn nicht an. - Library-Block: kind-picker (book/movie/series/comic) + LIBRARY_KEYS-Mapping (title, creators, year, review). - Space-Member-Block: role-picker (member/admin) + SPACE_KEYS-Mapping (nur 'email'). Hint "mappe genau ein Feld". - setMappingFor preserved jetzt alle target-spezifischen Felder (targetId, libraryKind, spaceMemberRole) damit ein Mapping-Edit nicht den Rest droppt. - 25 neue i18n-Keys × 5 Locales (autoSync.targetLibrary/SpaceMember, libraryKindPicker/libraryKind.*, libraryKey.*, libraryHint, spaceMemberRolePicker/RoleMember/RoleAdmin/Hint/MappingHint). Parity 6515 keys aligned. Trade-offs: - Library-Auto-Sync erzeugt einen Eintrag pro Antwort. Deduplizierung (gleicher Titel kommt schon vor) bleibt manueller User-Workflow — Autosync hat kein Wissen über die existierenden Bibliothek. - Space-Invite-Flow läuft asynchron: Submitter kriegt Mana-Mail mit Invite-Link, klickt → wird Member. Bei nicht-Mana-Identitäten muss der Submitter erst registrieren. Owner sieht den Pending-State unter /spaces. - feedback: bewusst nicht implementiert. Form-Antworten als public- feedback einzukippen wäre semantisch falsch (Owner sammelt für sich, nicht zur Veröffentlichung). Forms-Tests 70/70. svelte-check 0 errors. apps/api unverändert. i18n-parity 6515 keys × 5 locales aligned. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/i18n/locales/forms/de.json | 21 ++ .../web/src/lib/i18n/locales/forms/en.json | 21 ++ .../web/src/lib/i18n/locales/forms/es.json | 21 ++ .../web/src/lib/i18n/locales/forms/fr.json | 21 ++ .../web/src/lib/i18n/locales/forms/it.json | 21 ++ .../modules/forms/ConversationFormView.svelte | 2 +- .../forms/components/SettingsPanel.svelte | 187 +++++++++++++++--- .../apps/web/src/lib/modules/forms/index.ts | 2 + .../lib/modules/forms/lib/auto-sync.spec.ts | 95 ++++++++- .../src/lib/modules/forms/lib/auto-sync.ts | 134 +++++++++++-- .../web/src/lib/modules/forms/lib/csv.spec.ts | 1 + .../apps/web/src/lib/modules/forms/queries.ts | 1 + .../apps/web/src/lib/modules/forms/types.ts | 5 + 13 files changed, 497 insertions(+), 35 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 426f25604..5146878f6 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 @@ -102,6 +102,27 @@ "targetNone": "Nichts", "targetContacts": "Kontakt", "targetEvents": "Event-RSVP", + "targetLibrary": "Library-Eintrag", + "targetSpaceMember": "Space-Einladung", + "libraryKindPicker": "Welche Art von Eintrag?", + "libraryKind": { + "book": "Buch", + "movie": "Film", + "series": "Serie", + "comic": "Comic" + }, + "libraryKey": { + "title": "Titel", + "creators": "Autor:innen / Creator", + "year": "Jahr", + "review": "Notiz" + }, + "libraryHint": "Mappe ein Form-Feld auf \"Titel\" — der Rest ist optional. Empfohlen: zusätzlich Autor:innen + Jahr.", + "spaceMemberRolePicker": "Rolle bei Einladung", + "spaceMemberRoleMember": "Mitglied", + "spaceMemberRoleAdmin": "Admin", + "spaceMemberHint": "Antworten erzeugen eine Mana-Einladung an die angegebene E-Mail. Mappe genau ein Form-Feld auf \"E-Mail\".", + "spaceMemberMappingHint": "Mappe genau ein Feld auf \"E-Mail\". Andere Felder werden ignoriert.", "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?", 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 da50e815d..a698d3f1d 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 @@ -102,6 +102,27 @@ "targetNone": "None", "targetContacts": "Contact", "targetEvents": "Event RSVP", + "targetLibrary": "Library entry", + "targetSpaceMember": "Space invite", + "libraryKindPicker": "What kind of entry?", + "libraryKind": { + "book": "Book", + "movie": "Movie", + "series": "Series", + "comic": "Comic" + }, + "libraryKey": { + "title": "Title", + "creators": "Authors / creators", + "year": "Year", + "review": "Note" + }, + "libraryHint": "Map one form-field to \"Title\" — the rest is optional. Recommended: also creators + year.", + "spaceMemberRolePicker": "Role on invite", + "spaceMemberRoleMember": "Member", + "spaceMemberRoleAdmin": "Admin", + "spaceMemberHint": "Each submission triggers a Mana invite to the given email. Map exactly one form-field to \"Email\".", + "spaceMemberMappingHint": "Map exactly one field to \"Email\". Other fields are ignored.", "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?", 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 627f1fa08..c5a63ff01 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 @@ -102,6 +102,27 @@ "targetNone": "Nada", "targetContacts": "Contacto", "targetEvents": "RSVP de evento", + "targetLibrary": "Entrada de Library", + "targetSpaceMember": "Invitación al Space", + "libraryKindPicker": "¿Qué tipo de entrada?", + "libraryKind": { + "book": "Libro", + "movie": "Película", + "series": "Serie", + "comic": "Cómic" + }, + "libraryKey": { + "title": "Título", + "creators": "Autores / creadores", + "year": "Año", + "review": "Nota" + }, + "libraryHint": "Mapea un campo del formulario a \"Título\" — el resto es opcional. Recomendado: también autores + año.", + "spaceMemberRolePicker": "Rol al invitar", + "spaceMemberRoleMember": "Miembro", + "spaceMemberRoleAdmin": "Admin", + "spaceMemberHint": "Cada respuesta genera una invitación de Mana al correo indicado. Mapea exactamente un campo a \"Email\".", + "spaceMemberMappingHint": "Mapea exactamente un campo a \"Email\". Los demás se ignoran.", "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?", 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 c4135d678..e793cb24d 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 @@ -102,6 +102,27 @@ "targetNone": "Rien", "targetContacts": "Contact", "targetEvents": "RSVP événement", + "targetLibrary": "Entrée Library", + "targetSpaceMember": "Invitation Space", + "libraryKindPicker": "Quel type d'entrée ?", + "libraryKind": { + "book": "Livre", + "movie": "Film", + "series": "Série", + "comic": "BD" + }, + "libraryKey": { + "title": "Titre", + "creators": "Auteur·rice·s / créateur·rice·s", + "year": "Année", + "review": "Note" + }, + "libraryHint": "Mappe un champ vers « Titre » — le reste est optionnel. Recommandé : aussi auteur·rice·s + année.", + "spaceMemberRolePicker": "Rôle à l'invitation", + "spaceMemberRoleMember": "Membre", + "spaceMemberRoleAdmin": "Admin", + "spaceMemberHint": "Chaque soumission déclenche une invitation Mana à l'e-mail indiqué. Mappe exactement un champ sur « E-mail ».", + "spaceMemberMappingHint": "Mappe exactement un champ sur « E-mail ». Les autres sont ignorés.", "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 ?", 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 d8247b92b..3cb640617 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 @@ -102,6 +102,27 @@ "targetNone": "Nessuno", "targetContacts": "Contatto", "targetEvents": "RSVP evento", + "targetLibrary": "Voce di Library", + "targetSpaceMember": "Invito allo Space", + "libraryKindPicker": "Che tipo di voce?", + "libraryKind": { + "book": "Libro", + "movie": "Film", + "series": "Serie", + "comic": "Fumetto" + }, + "libraryKey": { + "title": "Titolo", + "creators": "Autori / creatori", + "year": "Anno", + "review": "Nota" + }, + "libraryHint": "Mappa un campo del modulo su \"Titolo\" — il resto è opzionale. Consigliato: anche autori + anno.", + "spaceMemberRolePicker": "Ruolo all'invito", + "spaceMemberRoleMember": "Membro", + "spaceMemberRoleAdmin": "Admin", + "spaceMemberHint": "Ogni risposta crea un invito Mana all'email indicata. Mappa esattamente un campo su \"Email\".", + "spaceMemberMappingHint": "Mappa esattamente un campo su \"Email\". Gli altri vengono ignorati.", "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?", diff --git a/apps/mana/apps/web/src/lib/modules/forms/ConversationFormView.svelte b/apps/mana/apps/web/src/lib/modules/forms/ConversationFormView.svelte index 6178fa366..4dd55cd03 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/ConversationFormView.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/ConversationFormView.svelte @@ -544,7 +544,7 @@ class="conv-input" type="text" bind:value={freeTextDraft} - placeholder="z.B. "der zweite Vorschlag"" + placeholder={'z.B. „der zweite Vorschlag“'} disabled={extracting} onkeydown={(e) => { if (e.key === 'Enter' && !extracting && freeTextDraft.trim()) { 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 d52480a72..f9df2d2a5 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 @@ -18,10 +18,10 @@ onchange: (patch: Partial) => void; } = $props(); - // 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']; + // M7a contacts, M7b events, M7c library + space_member. feedback bleibt + // als Type-Wert für Legacy-Daten erhalten, taucht aber nicht im UI auf: + // architektonischer Mismatch (zentraler public-hub statt Owner-Daten). + const SUPPORTED_TARGETS: AutoSyncTarget[] = ['contacts', 'events', 'library', 'space_member']; const events$ = useAllEvents(); const events = $derived(events$.value); @@ -85,6 +85,47 @@ const GUEST_KEYS = ['name', 'email', 'phone', 'note', 'plusOnes'] as const; type GuestKey = (typeof GUEST_KEYS)[number]; + const LIBRARY_KEYS = ['title', 'creators', 'year', 'review'] as const; + type LibraryKey = (typeof LIBRARY_KEYS)[number]; + + function libraryKeyLabel(key: LibraryKey): string { + switch (key) { + case 'title': + return $_('forms.builder.autoSync.libraryKey.title', { default: 'Titel' }); + case 'creators': + return $_('forms.builder.autoSync.libraryKey.creators', { + default: 'Autor:innen / Creator', + }); + case 'year': + return $_('forms.builder.autoSync.libraryKey.year', { default: 'Jahr' }); + case 'review': + return $_('forms.builder.autoSync.libraryKey.review', { default: 'Notiz' }); + } + } + + const SPACE_KEYS = ['email'] as const; + type SpaceKey = (typeof SPACE_KEYS)[number]; + + function spaceKeyLabel(key: SpaceKey): string { + return key === 'email' ? 'E-Mail' : key; + } + + const LIBRARY_KINDS = ['book', 'movie', 'series', 'comic'] as const; + type LibraryKind = (typeof LIBRARY_KINDS)[number]; + + function libraryKindLabel(k: LibraryKind): string { + switch (k) { + case 'book': + return $_('forms.builder.autoSync.libraryKind.book', { default: 'Buch' }); + case 'movie': + return $_('forms.builder.autoSync.libraryKind.movie', { default: 'Film' }); + case 'series': + return $_('forms.builder.autoSync.libraryKind.series', { default: 'Serie' }); + case 'comic': + return $_('forms.builder.autoSync.libraryKind.comic', { default: 'Comic' }); + } + } + function guestKeyLabel(key: GuestKey): string { switch (key) { case 'name': @@ -107,21 +148,29 @@ const target = $derived(settings.autoSync?.target ?? 'none'); const targetId = $derived(settings.autoSync?.targetId ?? ''); const mapping = $derived(settings.autoSync?.mapping ?? {}); + const libraryKind = $derived( + (settings.autoSync?.libraryKind as LibraryKind | undefined) ?? 'book' + ); + const spaceMemberRole = $derived<'member' | 'admin'>( + settings.autoSync?.spaceMemberRole ?? 'member' + ); 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: {}, - ...(next === 'events' && targetId ? { targetId } : {}), - }, - }); + return; } + // Switching target → drop mapping (different target, different + // allowed keys). target-spezifische Felder bleiben erhalten. + onchange({ + autoSync: { + target: next, + mapping: {}, + ...(next === 'events' && targetId ? { targetId } : {}), + ...(next === 'library' ? { libraryKind } : {}), + ...(next === 'space_member' ? { spaceMemberRole } : {}), + }, + }); } function setTargetId(eventId: string) { @@ -134,6 +183,26 @@ }); } + function setLibraryKind(kind: LibraryKind) { + onchange({ + autoSync: { + target: 'library', + libraryKind: kind, + mapping, + }, + }); + } + + function setSpaceMemberRole(role: 'member' | 'admin') { + onchange({ + autoSync: { + target: 'space_member', + spaceMemberRole: role, + mapping, + }, + }); + } + function setMappingFor(fieldId: string, key: string) { const next = { ...mapping }; if (!key) { @@ -141,13 +210,15 @@ } else { next[fieldId] = key; } - const t = settings.autoSync?.target ?? 'contacts'; - const tid = settings.autoSync?.targetId; + const cfg = settings.autoSync; + const t = cfg?.target ?? 'contacts'; onchange({ autoSync: { target: t, mapping: next, - ...(tid ? { targetId: tid } : {}), + ...(cfg?.targetId ? { targetId: cfg.targetId } : {}), + ...(cfg?.libraryKind ? { libraryKind: cfg.libraryKind } : {}), + ...(cfg?.spaceMemberRole ? { spaceMemberRole: cfg.spaceMemberRole } : {}), }, }); } @@ -379,7 +450,15 @@ ? $_('forms.builder.autoSync.targetContacts', { default: 'Kontakt' }) : t === 'events' ? $_('forms.builder.autoSync.targetEvents', { default: 'Event-RSVP' }) - : t} + : t === 'library' + ? $_('forms.builder.autoSync.targetLibrary', { + default: 'Library-Eintrag', + }) + : t === 'space_member' + ? $_('forms.builder.autoSync.targetSpaceMember', { + default: 'Space-Einladung', + }) + : t} {/each} @@ -410,6 +489,53 @@ {/if} {/if} + {#if target === 'library'} + + {/if} + + {#if target === 'space_member'} + +

+ {$_('forms.builder.autoSync.spaceMemberHint', { + default: + 'Antworten erzeugen eine Mana-Einladung an die angegebene E-Mail. Mappe genau ein Form-Feld auf "E-Mail".', + })} +

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

@@ -424,10 +550,19 @@ 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.', - })} + : target === 'library' + ? $_('forms.builder.autoSync.libraryHint', { + default: + 'Mappe ein Form-Feld auf "Titel" — der Rest ist optional. Empfohlen: zusätzlich Autor:innen + Jahr.', + }) + : target === 'space_member' + ? $_('forms.builder.autoSync.spaceMemberMappingHint', { + default: 'Mappe genau ein Feld auf "E-Mail". Andere Felder werden ignoriert.', + }) + : $_('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)} @@ -445,6 +580,14 @@ {#each GUEST_KEYS as gk} {/each} + {:else if target === 'library'} + {#each LIBRARY_KEYS as lk} + + {/each} + {:else if target === 'space_member'} + {#each SPACE_KEYS as sk} + + {/each} {:else} {#each CONTACT_KEYS as ck} 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 4c1532cac..e32d0aa93 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/index.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/index.ts @@ -23,6 +23,8 @@ export { buildResponsesCsv, downloadResponsesCsv } from './lib/csv'; export { buildContactFromAnswers, buildEventGuestFromAnswers, + buildLibraryEntryFromAnswers, + buildSpaceInviteFromAnswers, applyAutoSync, runAutoSyncSweep, } from './lib/auto-sync'; 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 8b2f5a3c2..e6d0ae0f6 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,10 @@ import { describe, it, expect } from 'vitest'; -import { buildContactFromAnswers, buildEventGuestFromAnswers } from './auto-sync'; +import { + buildContactFromAnswers, + buildEventGuestFromAnswers, + buildLibraryEntryFromAnswers, + buildSpaceInviteFromAnswers, +} from './auto-sync'; describe('buildContactFromAnswers', () => { it('maps form-fields to contact-fields directly', () => { @@ -101,3 +106,91 @@ describe('buildEventGuestFromAnswers', () => { expect(result).toEqual({ name: 'Anna Mustermann' }); }); }); + +describe('buildLibraryEntryFromAnswers', () => { + it('maps title / creators / year / review', () => { + expect( + buildLibraryEntryFromAnswers( + { + 'f-title': 'Dune', + 'f-creators': 'Frank Herbert', + 'f-year': '1965', + 'f-review': 'Tolles Buch', + }, + { + 'f-title': 'title', + 'f-creators': 'creators', + 'f-year': 'year', + 'f-review': 'review', + } + ) + ).toEqual({ + title: 'Dune', + creators: ['Frank Herbert'], + year: 1965, + review: 'Tolles Buch', + }); + }); + + it('splits multi-creator strings on comma/semicolon/newline', () => { + expect( + buildLibraryEntryFromAnswers( + { 'f-c': 'Frank Herbert; Brian Herbert\nKevin J. Anderson' }, + { 'f-c': 'creators' } + ) + ).toEqual({ creators: ['Frank Herbert', 'Brian Herbert', 'Kevin J. Anderson'] }); + }); + + it('rejects out-of-range years', () => { + expect(buildLibraryEntryFromAnswers({ 'f-y': '1800' }, { 'f-y': 'year' })).toEqual({}); + expect(buildLibraryEntryFromAnswers({ 'f-y': '2200' }, { 'f-y': 'year' })).toEqual({}); + expect(buildLibraryEntryFromAnswers({ 'f-y': '2026' }, { 'f-y': 'year' })).toEqual({ + year: 2026, + }); + }); + + it('skips empty / non-string values gracefully', () => { + expect( + buildLibraryEntryFromAnswers( + { 'f-title': '', 'f-year': null }, + { 'f-title': 'title', 'f-year': 'year' } + ) + ).toEqual({}); + }); +}); + +describe('buildSpaceInviteFromAnswers', () => { + it('extracts the first valid email mapped to "email"', () => { + expect( + buildSpaceInviteFromAnswers({ 'f-mail': 'anna@example.com' }, { 'f-mail': 'email' }) + ).toEqual({ email: 'anna@example.com' }); + }); + + it('rejects malformed emails', () => { + expect( + buildSpaceInviteFromAnswers({ 'f-mail': 'not-an-email' }, { 'f-mail': 'email' }) + ).toEqual({}); + expect(buildSpaceInviteFromAnswers({ 'f-mail': 'foo@' }, { 'f-mail': 'email' })).toEqual({}); + }); + + it('ignores fields not mapped to "email"', () => { + expect( + buildSpaceInviteFromAnswers( + { 'f-name': 'Anna', 'f-mail': 'anna@example.com' }, + { 'f-name': 'name', 'f-mail': 'email' } + ) + ).toEqual({ email: 'anna@example.com' }); + }); + + it('returns empty when no mapping has key=email', () => { + expect(buildSpaceInviteFromAnswers({ 'f-name': 'Anna' }, { 'f-name': 'firstName' })).toEqual( + {} + ); + }); + + it('returns empty for non-string answers', () => { + expect( + buildSpaceInviteFromAnswers({ 'f-mail': null as unknown as string }, { 'f-mail': 'email' }) + ).toEqual({}); + }); +}); 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 9b640f84a..427129678 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 @@ -23,6 +23,8 @@ import { contactsStore } from '$lib/modules/contacts/stores/contacts.svelte'; import { eventGuestsStore } from '$lib/modules/events/stores/guests.svelte'; +import { libraryEntriesStore } from '$lib/modules/library/stores/entries.svelte'; +import { authFetch } from '$lib/data/scope'; import { decryptRecords, isVaultUnlocked } from '$lib/data/crypto'; import { formResponseTable, formTable } from '../collections'; import { toForm, toFormResponse } from '../queries'; @@ -82,7 +84,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, response.answers); + const recordId = await dispatchTarget(cfg, response.answers, form); if (!recordId) return { synced: false }; const next = [...(response.syncedTargets ?? []), { target: cfg.target, recordId }]; @@ -139,9 +141,79 @@ export function buildEventGuestFromAnswers( return guest; } +/** + * Build a library-entry patch from a form response. The mapping is + * narrower than for contacts: library entries are dominated by `title`, + * with optional creators / year / review. Pure. + */ +export function buildLibraryEntryFromAnswers( + answers: Record, + mapping: Record +): { + title?: string; + creators?: string[]; + year?: number; + review?: string; +} { + const out: { title?: string; creators?: string[]; year?: number; review?: string } = {}; + 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 'title': + out.title = str; + break; + case 'creator': + case 'creators': { + const arr = str + .split(/[,;\n]+/) + .map((s) => s.trim()) + .filter(Boolean); + if (arr.length > 0) out.creators = arr; + break; + } + case 'year': { + const n = Number(str); + if (Number.isFinite(n) && n >= 1900 && n <= 2100) out.year = Math.round(n); + break; + } + case 'review': + out.review = str; + break; + } + } + return out; +} + +/** + * Build a space-invite payload from a form response. Just the email — + * the role lives on the autoSync config, not on the form-fields. + */ +export function buildSpaceInviteFromAnswers( + answers: Record, + mapping: Record +): { email?: string } { + const out: { email?: string } = {}; + for (const [fieldId, key] of Object.entries(mapping)) { + if (key !== 'email') continue; + const value = answers[fieldId]; + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (!trimmed) continue; + // Loose email shape — better-auth validates strictly server-side. + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) continue; + out.email = trimmed; + break; + } + return out; +} + async function dispatchTarget( cfg: AutoSyncConfig, - answers: Record + answers: Record, + form: Form ): Promise { switch (cfg.target) { case 'contacts': { @@ -173,16 +245,56 @@ async function dispatchTarget( }); return result.success ? result.id : null; } + case 'library': { + if (!cfg.libraryKind) { + throw new Error('autoSync.libraryKind ist erforderlich für target=library'); + } + const data = buildLibraryEntryFromAnswers(answers, cfg.mapping); + // Library entries need a title — a creator-only or year-only + // row would be useless. + if (!data.title) return null; + const entry = await libraryEntriesStore.createEntry({ + kind: cfg.libraryKind, + title: data.title, + creators: data.creators, + year: data.year ?? null, + review: data.review ?? null, + }); + return entry?.id ?? null; + } + case 'space_member': { + const invite = buildSpaceInviteFromAnswers(answers, cfg.mapping); + if (!invite.email) return null; + if (!form.spaceId) { + throw new Error('Form hat keine spaceId — Space-Invite kann nicht gesendet werden'); + } + const role = cfg.spaceMemberRole ?? 'member'; + const res = await authFetch('/api/auth/organization/invite-member', { + method: 'POST', + body: JSON.stringify({ + email: invite.email, + role, + organizationId: form.spaceId, + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Invite fehlgeschlagen (${res.status}): ${text.slice(0, 200)}`); + } + const data = (await res.json().catch(() => ({}))) as { invitation?: { id?: string } }; + return data.invitation?.id ?? `invite:${invite.email}`; + } case 'feedback': - case 'library': - case 'space_member': - // 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`); + // Architektur-Mismatch: @mana/feedback ist ein zentraler + // Public-Hub mit eigener Domain (feedback.mana.how), keine + // per-User Dexie-Tabelle. Form-Antworten dort einzukippen würde + // bedeuten, im Namen des Submitters eine Public-Feedback zu + // erzeugen — semantisch falsch (das Owner-Form sammelt die + // Antworten _für sich_, nicht zur Veröffentlichung). Bleibt + // explicit unsupported. UI filtert die Option raus. + throw new Error( + 'autoSync target "feedback" ist nicht supportet — feedback ist zentraler Public-Hub' + ); } } diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts index b31b5bc3a..1e50248e0 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts @@ -6,6 +6,7 @@ import type { Form, FormResponse } from '../types'; function makeForm(): Form { return { id: 'f1', + spaceId: 's1', title: 'Pulse Check', description: null, fields: [ diff --git a/apps/mana/apps/web/src/lib/modules/forms/queries.ts b/apps/mana/apps/web/src/lib/modules/forms/queries.ts index 43b7377ea..030538314 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/queries.ts @@ -17,6 +17,7 @@ import type { export function toForm(local: LocalForm): Form { return { id: local.id, + spaceId: (local as unknown as { spaceId?: string }).spaceId ?? '', title: local.title, description: local.description, fields: local.fields ?? [], 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 6a82f59b5..e29d0010c 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/types.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/types.ts @@ -75,6 +75,10 @@ export interface AutoSyncConfig { /** Optional anchor — for `events` this is the eventId the response RSVPs to. */ targetId?: string; mapping: Record; + /** M7c — for `library`: which kind of media-entry to create. */ + libraryKind?: 'book' | 'movie' | 'series' | 'comic'; + /** M7c — for `space_member`: role to assign on invite (default 'member'). */ + spaceMemberRole?: 'member' | 'admin'; } /** @@ -173,6 +177,7 @@ export interface LocalFormResponse extends BaseRecord { export interface Form { id: string; + spaceId: string; title: string; description: string | null; fields: FormField[];