mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
6d67db48d5
commit
82dbfe6ee7
13 changed files with 497 additions and 35 deletions
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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 ?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export { buildResponsesCsv, downloadResponsesCsv } from './lib/csv';
|
|||
export {
|
||||
buildContactFromAnswers,
|
||||
buildEventGuestFromAnswers,
|
||||
buildLibraryEntryFromAnswers,
|
||||
buildSpaceInviteFromAnswers,
|
||||
applyAutoSync,
|
||||
runAutoSyncSweep,
|
||||
} from './lib/auto-sync';
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { Form, FormResponse } from '../types';
|
|||
function makeForm(): Form {
|
||||
return {
|
||||
id: 'f1',
|
||||
spaceId: 's1',
|
||||
title: 'Pulse Check',
|
||||
description: null,
|
||||
fields: [
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue