feat(forms): M7c auto-sync zu library + space_member

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:<email>` (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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-06 16:27:40 +02:00
parent 6d67db48d5
commit 82dbfe6ee7
13 changed files with 497 additions and 35 deletions

View file

@ -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?",

View file

@ -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?",

View file

@ -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?",

View file

@ -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 ?",

View file

@ -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?",

View file

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

View file

@ -18,10 +18,10 @@
onchange: (patch: Partial<FormSettings>) => 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<AutoSyncTarget | 'none'>(settings.autoSync?.target ?? 'none');
const targetId = $derived(settings.autoSync?.targetId ?? '');
const mapping = $derived(settings.autoSync?.mapping ?? {});
const libraryKind = $derived<LibraryKind>(
(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}
</option>
{/each}
</select>
@ -410,6 +489,53 @@
{/if}
{/if}
{#if target === 'library'}
<label class="setting-row">
<span class="setting-label">
{$_('forms.builder.autoSync.libraryKindPicker', {
default: 'Welche Art von Eintrag?',
})}
</span>
<select
value={libraryKind}
onchange={(e) =>
setLibraryKind((e.currentTarget as HTMLSelectElement).value as LibraryKind)}
>
{#each LIBRARY_KINDS as k}
<option value={k}>{libraryKindLabel(k)}</option>
{/each}
</select>
</label>
{/if}
{#if target === 'space_member'}
<label class="setting-row">
<span class="setting-label">
{$_('forms.builder.autoSync.spaceMemberRolePicker', {
default: 'Rolle bei Einladung',
})}
</span>
<select
value={spaceMemberRole}
onchange={(e) =>
setSpaceMemberRole((e.currentTarget as HTMLSelectElement).value as 'member' | 'admin')}
>
<option value="member">
{$_('forms.builder.autoSync.spaceMemberRoleMember', { default: 'Mitglied' })}
</option>
<option value="admin">
{$_('forms.builder.autoSync.spaceMemberRoleAdmin', { default: 'Admin' })}
</option>
</select>
</label>
<p class="hint">
{$_('forms.builder.autoSync.spaceMemberHint', {
default:
'Antworten erzeugen eine Mana-Einladung an die angegebene E-Mail. Mappe genau ein Form-Feld auf "E-Mail".',
})}
</p>
{/if}
{#if target !== 'none' && (target !== 'events' || targetId)}
{#if ANSWER_FIELDS.length === 0}
<p class="hint">
@ -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.',
})}
</p>
<div class="mapping-grid">
{#each ANSWER_FIELDS as f (f.id)}
@ -445,6 +580,14 @@
{#each GUEST_KEYS as gk}
<option value={gk}>{guestKeyLabel(gk)}</option>
{/each}
{:else if target === 'library'}
{#each LIBRARY_KEYS as lk}
<option value={lk}>{libraryKeyLabel(lk)}</option>
{/each}
{:else if target === 'space_member'}
{#each SPACE_KEYS as sk}
<option value={sk}>{spaceKeyLabel(sk)}</option>
{/each}
{:else}
{#each CONTACT_KEYS as ck}
<option value={ck}>{contactKeyLabel(ck)}</option>

View file

@ -23,6 +23,8 @@ export { buildResponsesCsv, downloadResponsesCsv } from './lib/csv';
export {
buildContactFromAnswers,
buildEventGuestFromAnswers,
buildLibraryEntryFromAnswers,
buildSpaceInviteFromAnswers,
applyAutoSync,
runAutoSyncSweep,
} from './lib/auto-sync';

View file

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

View file

@ -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<string, AnswerValue>,
mapping: Record<string, string>
): {
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<string, AnswerValue>,
mapping: Record<string, string>
): { 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<string, AnswerValue>
answers: Record<string, AnswerValue>,
form: Form
): Promise<string | null> {
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'
);
}
}

View file

@ -6,6 +6,7 @@ import type { Form, FormResponse } from '../types';
function makeForm(): Form {
return {
id: 'f1',
spaceId: 's1',
title: 'Pulse Check',
description: null,
fields: [

View file

@ -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 ?? [],

View file

@ -75,6 +75,10 @@ export interface AutoSyncConfig {
/** Optional anchor — for `events` this is the eventId the response RSVPs to. */
targetId?: string;
mapping: Record<string, string>;
/** 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[];