mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(forms): M7a auto-sync zu contacts — der Mana-Differentiator
Bei einer neuen Form-Antwort entsteht automatisch ein Kontakt im
contacts-Modul (docs/plans/forms-module.md M7 — Teil 1):
- lib/auto-sync.ts:
- buildContactFromAnswers (pure): Mapping form-field-id →
contact-key (firstName/lastName/email/phone/...). Special target
`name` splittet auf erstes Whitespace in firstName + lastName.
- applyAutoSync (per Response): idempotent via syncedTargets-Check,
schreibt nach Erfolg `{target, recordId}` ans Response-Row.
- runAutoSyncSweep: scant alle Forms mit autoSync, dekrypt-aware
(vault-locked = no-op), filtert pre-decrypt auf nicht-bereits-
synced Responses für günstigen Skip. Per-Response-Errors werden
geloggt aber blockieren den Rest nicht.
- dispatchTarget für 'events' / 'feedback' / 'library' /
'space_member' wirft "M7b not yet" — Surface ist da, UI filtert.
- lib/auto-sync.spec.ts: 6/6 Vitest-Cases.
- SettingsPanel: target-Dropdown ('Nichts' / 'Kontakt') + bei contacts
Mapping-Grid über alle Antwortfelder mit dropdown der 15 contact-
keys (name als auto-split, sonst firstName/lastName/email/phone/
mobile/company/jobTitle/street/city/postalCode/country/birthday/
website/notes).
- BuilderView reicht items-Field-Liste an SettingsPanel weiter.
- ResponsesView triggert runAutoSyncSweep on-mount + bei Response-
Liste-Änderung. Bei synced > 0: Toast "{n} automatisch
synchronisiert" 4 Sek lang.
- 8 neue i18n-Keys × 5 Locales (forms.builder.autoSync.*).
Total Forms-Tests: 22/22 (5 csv + 11 branching + 6 auto-sync).
svelte-check 0 errors. i18n-parity 6407 keys.
Future M7b: events (RSVP), feedback, library, space_member.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
59373c0d57
commit
7f805d9da2
11 changed files with 548 additions and 4 deletions
|
|
@ -75,6 +75,17 @@
|
|||
"title": "Sichtbarkeit & Teilen",
|
||||
"publishHint": "Setze den Status auf \"Veröffentlicht\", um zu teilen."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-Sync — bei Antwort erzeugen",
|
||||
"targetNone": "Nichts",
|
||||
"targetContacts": "Kontakt",
|
||||
"contactsHint": "Wähle für jedes Form-Feld, welches Kontakt-Feld es füllen soll. Leerlassen = ignorieren.",
|
||||
"needFields": "Lege mindestens ein Antwortfeld an, um Mapping zu konfigurieren.",
|
||||
"ignore": "Ignorieren",
|
||||
"contactKey": {
|
||||
"name": "Vor- + Nachname (auto split)"
|
||||
}
|
||||
},
|
||||
"viewResponses": "Antworten ({n})"
|
||||
},
|
||||
"responses": {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,17 @@
|
|||
"title": "Visibility & Sharing",
|
||||
"publishHint": "Set the status to \"Published\" to share."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-sync — create on submit",
|
||||
"targetNone": "None",
|
||||
"targetContacts": "Contact",
|
||||
"contactsHint": "For each form field, pick which contact field it should fill. Leave empty to ignore.",
|
||||
"needFields": "Add at least one answer field to configure mapping.",
|
||||
"ignore": "Ignore",
|
||||
"contactKey": {
|
||||
"name": "First + last name (auto split)"
|
||||
}
|
||||
},
|
||||
"viewResponses": "Responses ({n})"
|
||||
},
|
||||
"responses": {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,17 @@
|
|||
"title": "Visibilidad y compartir",
|
||||
"publishHint": "Pon el estado en \"Publicado\" para compartir."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-sync — crear al recibir respuesta",
|
||||
"targetNone": "Nada",
|
||||
"targetContacts": "Contacto",
|
||||
"contactsHint": "Para cada campo del formulario, elige qué campo del contacto debe rellenar. Deja vacío para ignorar.",
|
||||
"needFields": "Añade al menos un campo de respuesta para configurar el mapeo.",
|
||||
"ignore": "Ignorar",
|
||||
"contactKey": {
|
||||
"name": "Nombre + apellido (auto split)"
|
||||
}
|
||||
},
|
||||
"viewResponses": "Respuestas ({n})"
|
||||
},
|
||||
"responses": {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,17 @@
|
|||
"title": "Visibilité et partage",
|
||||
"publishHint": "Mets le statut sur \"Publié\" pour partager."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-sync — créer à la soumission",
|
||||
"targetNone": "Rien",
|
||||
"targetContacts": "Contact",
|
||||
"contactsHint": "Pour chaque champ du formulaire, choisis quel champ de contact remplir. Laisse vide pour ignorer.",
|
||||
"needFields": "Ajoute au moins un champ de réponse pour configurer le mapping.",
|
||||
"ignore": "Ignorer",
|
||||
"contactKey": {
|
||||
"name": "Prénom + nom (auto split)"
|
||||
}
|
||||
},
|
||||
"viewResponses": "Réponses ({n})"
|
||||
},
|
||||
"responses": {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,17 @@
|
|||
"title": "Visibilità e condivisione",
|
||||
"publishHint": "Imposta lo stato su \"Pubblicato\" per condividere."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-sync — crea al ricevere risposta",
|
||||
"targetNone": "Nessuno",
|
||||
"targetContacts": "Contatto",
|
||||
"contactsHint": "Per ogni campo del modulo, scegli quale campo del contatto deve riempire. Lascia vuoto per ignorare.",
|
||||
"needFields": "Aggiungi almeno un campo di risposta per configurare il mapping.",
|
||||
"ignore": "Ignora",
|
||||
"contactKey": {
|
||||
"name": "Nome + cognome (auto split)"
|
||||
}
|
||||
},
|
||||
"viewResponses": "Risposte ({n})"
|
||||
},
|
||||
"responses": {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,113 @@
|
|||
<!--
|
||||
SettingsPanel — form-level settings (submit button, success message,
|
||||
email-required, multiple-submissions). The visibility/share controls
|
||||
land in M4 alongside the @mana/shared-privacy wiring.
|
||||
email-required, multiple-submissions, auto-sync). Visibility/share
|
||||
controls leben in BuilderView, das @mana/shared-privacy direkt nutzt.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { FormSettings } from '../types';
|
||||
import type { AutoSyncTarget, FormField, FormSettings } from '../types';
|
||||
|
||||
let {
|
||||
settings,
|
||||
fields,
|
||||
onchange,
|
||||
}: {
|
||||
settings: FormSettings;
|
||||
fields: FormField[];
|
||||
onchange: (patch: Partial<FormSettings>) => void;
|
||||
} = $props();
|
||||
|
||||
// v1: nur 'contacts' implementiert (M7a). Andere Targets sind
|
||||
// strukturell vorgesehen, aber die dispatchTarget-Branch wirft —
|
||||
// wir filtern sie aus dem UI raus, bis M7b sie scharfschaltet.
|
||||
const SUPPORTED_TARGETS: AutoSyncTarget[] = ['contacts'];
|
||||
|
||||
const CONTACT_KEYS = [
|
||||
'name',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'email',
|
||||
'phone',
|
||||
'mobile',
|
||||
'company',
|
||||
'jobTitle',
|
||||
'street',
|
||||
'city',
|
||||
'postalCode',
|
||||
'country',
|
||||
'birthday',
|
||||
'website',
|
||||
'notes',
|
||||
] as const;
|
||||
type ContactKey = (typeof CONTACT_KEYS)[number];
|
||||
|
||||
function contactKeyLabel(key: ContactKey): string {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return $_('forms.builder.autoSync.contactKey.name', {
|
||||
default: 'Vor- + Nachname (auto split)',
|
||||
});
|
||||
case 'firstName':
|
||||
return 'Vorname';
|
||||
case 'lastName':
|
||||
return 'Nachname';
|
||||
case 'email':
|
||||
return 'E-Mail';
|
||||
case 'phone':
|
||||
return 'Telefon (Festnetz)';
|
||||
case 'mobile':
|
||||
return 'Mobil';
|
||||
case 'company':
|
||||
return 'Firma';
|
||||
case 'jobTitle':
|
||||
return 'Rolle / Position';
|
||||
case 'street':
|
||||
return 'Straße';
|
||||
case 'city':
|
||||
return 'Ort';
|
||||
case 'postalCode':
|
||||
return 'PLZ';
|
||||
case 'country':
|
||||
return 'Land';
|
||||
case 'birthday':
|
||||
return 'Geburtstag';
|
||||
case 'website':
|
||||
return 'Website';
|
||||
case 'notes':
|
||||
return 'Notiz';
|
||||
}
|
||||
}
|
||||
|
||||
const ANSWER_FIELDS = $derived(
|
||||
fields.filter((f) => f.type !== 'section' && f.type !== 'consent')
|
||||
);
|
||||
|
||||
const target = $derived<AutoSyncTarget | 'none'>(settings.autoSync?.target ?? 'none');
|
||||
const mapping = $derived(settings.autoSync?.mapping ?? {});
|
||||
|
||||
function setTarget(next: AutoSyncTarget | 'none') {
|
||||
if (next === 'none') {
|
||||
onchange({ autoSync: undefined });
|
||||
} else {
|
||||
onchange({
|
||||
autoSync: {
|
||||
target: next,
|
||||
mapping: settings.autoSync?.mapping ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setMappingFor(fieldId: string, contactKey: ContactKey | '') {
|
||||
const next = { ...mapping };
|
||||
if (!contactKey) {
|
||||
delete next[fieldId];
|
||||
} else {
|
||||
next[fieldId] = contactKey;
|
||||
}
|
||||
const t = settings.autoSync?.target ?? 'contacts';
|
||||
onchange({ autoSync: { target: t, mapping: next } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings-panel">
|
||||
|
|
@ -92,6 +186,70 @@
|
|||
})}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="auto-sync-block">
|
||||
<p class="block-title">
|
||||
{$_('forms.builder.autoSync.title', { default: 'Auto-Sync — bei Antwort erzeugen' })}
|
||||
</p>
|
||||
|
||||
<select
|
||||
class="target-select"
|
||||
value={target}
|
||||
onchange={(e) =>
|
||||
setTarget((e.currentTarget as HTMLSelectElement).value as AutoSyncTarget | 'none')}
|
||||
>
|
||||
<option value="none">
|
||||
{$_('forms.builder.autoSync.targetNone', { default: 'Nichts' })}
|
||||
</option>
|
||||
{#each SUPPORTED_TARGETS as t}
|
||||
<option value={t}>
|
||||
{t === 'contacts'
|
||||
? $_('forms.builder.autoSync.targetContacts', { default: 'Kontakt' })
|
||||
: t}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if target === 'contacts'}
|
||||
{#if ANSWER_FIELDS.length === 0}
|
||||
<p class="hint">
|
||||
{$_('forms.builder.autoSync.needFields', {
|
||||
default: 'Lege mindestens ein Antwortfeld an, um Mapping zu konfigurieren.',
|
||||
})}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="hint">
|
||||
{$_('forms.builder.autoSync.contactsHint', {
|
||||
default:
|
||||
'Wähle für jedes Form-Feld, welches Kontakt-Feld es füllen soll. Leerlassen = ignorieren.',
|
||||
})}
|
||||
</p>
|
||||
<div class="mapping-grid">
|
||||
{#each ANSWER_FIELDS as f (f.id)}
|
||||
<div class="mapping-row">
|
||||
<span class="form-field-label" title={f.label}>{f.label}</span>
|
||||
<select
|
||||
class="contact-key-select"
|
||||
value={mapping[f.id] ?? ''}
|
||||
onchange={(e) =>
|
||||
setMappingFor(
|
||||
f.id,
|
||||
(e.currentTarget as HTMLSelectElement).value as ContactKey | ''
|
||||
)}
|
||||
>
|
||||
<option value="">
|
||||
{$_('forms.builder.autoSync.ignore', { default: 'Ignorieren' })}
|
||||
</option>
|
||||
{#each CONTACT_KEYS as ck}
|
||||
<option value={ck}>{contactKeyLabel(ck)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -144,4 +302,68 @@
|
|||
color: rgb(255 255 255 / 0.8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-sync-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
}
|
||||
|
||||
.target-select {
|
||||
max-width: 240px;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.mapping-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-field-label {
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(255 255 255 / 0.75);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-key-select {
|
||||
min-width: 200px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.25rem;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export { formTable, formResponseTable } from './collections';
|
|||
export { makeDefaultField } from './lib/field-defaults';
|
||||
export { resolveVisibleFields } from './lib/branching';
|
||||
export { buildResponsesCsv, downloadResponsesCsv } from './lib/csv';
|
||||
export { buildContactFromAnswers, applyAutoSync, runAutoSyncSweep } from './lib/auto-sync';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { buildContactFromAnswers } from './auto-sync';
|
||||
|
||||
describe('buildContactFromAnswers', () => {
|
||||
it('maps form-fields to contact-fields directly', () => {
|
||||
const result = buildContactFromAnswers(
|
||||
{ 'f-name': 'Anna Mustermann', 'f-email': 'anna@example.com', 'f-phone': '+49 30 12345' },
|
||||
{ 'f-name': 'firstName', 'f-email': 'email', 'f-phone': 'phone' }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
firstName: 'Anna Mustermann',
|
||||
email: 'anna@example.com',
|
||||
phone: '+49 30 12345',
|
||||
});
|
||||
});
|
||||
|
||||
it('special-cases the synthetic "name" target by splitting on first whitespace', () => {
|
||||
const result = buildContactFromAnswers(
|
||||
{ 'f-name': 'Anna Mustermann von Beispiel' },
|
||||
{ 'f-name': 'name' }
|
||||
);
|
||||
expect(result).toEqual({ firstName: 'Anna', lastName: 'Mustermann von Beispiel' });
|
||||
});
|
||||
|
||||
it('puts a single-word name into firstName and leaves lastName unset', () => {
|
||||
const result = buildContactFromAnswers({ 'f-name': 'Madonna' }, { 'f-name': 'name' });
|
||||
expect(result).toEqual({ firstName: 'Madonna' });
|
||||
});
|
||||
|
||||
it('skips empty / null / undefined answers', () => {
|
||||
const result = buildContactFromAnswers(
|
||||
{ 'f-name': '', 'f-email': null, 'f-phone': undefined as unknown as string },
|
||||
{ 'f-name': 'firstName', 'f-email': 'email', 'f-phone': 'phone' }
|
||||
);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('coerces non-string answers to string before mapping', () => {
|
||||
const result = buildContactFromAnswers(
|
||||
{ 'f-num': 42 as unknown as string },
|
||||
{ 'f-num': 'phone' }
|
||||
);
|
||||
expect(result).toEqual({ phone: '42' });
|
||||
});
|
||||
|
||||
it('ignores form-fields that have no mapping', () => {
|
||||
const result = buildContactFromAnswers(
|
||||
{ 'f-name': 'Anna', 'f-extra': 'something' },
|
||||
{ 'f-name': 'firstName' }
|
||||
);
|
||||
expect(result).toEqual({ firstName: 'Anna' });
|
||||
});
|
||||
});
|
||||
168
apps/mana/apps/web/src/lib/modules/forms/lib/auto-sync.ts
Normal file
168
apps/mana/apps/web/src/lib/modules/forms/lib/auto-sync.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Forms — auto-sync to other modules (M7a).
|
||||
*
|
||||
* Bei einer neuen Form-Antwort kann der Owner konfigurieren, dass die
|
||||
* Antwort automatisch in ein anderes Modul fliesst. v1 unterstuetzt
|
||||
* `contacts` — typischer Vereins-Anmeldungs-Flow:
|
||||
* form-field-X (Name) → contact.firstName + lastName
|
||||
* form-field-Y (Email) → contact.email
|
||||
* form-field-Z (Telefon) → contact.phone
|
||||
*
|
||||
* Der Hook ist owner-side: laeuft, wenn der Client eine neue Antwort
|
||||
* sieht (Pull oder lokaler Insert) UND die Antwort noch keinen
|
||||
* `syncedTargets`-Eintrag fuer das konfigurierte Target hat. Der
|
||||
* Server kann das nicht — er hat keinen master-key fuer encrypted
|
||||
* tables wie `contacts`. Owner-side ist die richtige Stelle.
|
||||
*
|
||||
* Idempotenz: jede Anwendung schreibt einen `syncedTargets`-Eintrag
|
||||
* mit { target, recordId }. Beim naechsten Scan wird die Antwort
|
||||
* uebersprungen, weil der Eintrag bereits drin ist.
|
||||
*
|
||||
* Plan: docs/plans/forms-module.md M7.
|
||||
*/
|
||||
|
||||
import { contactsStore } from '$lib/modules/contacts/stores/contacts.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';
|
||||
|
||||
/**
|
||||
* Build a contact-record patch from a form response, given the
|
||||
* field-mapping configured by the form owner. Pure — no Dexie writes.
|
||||
*
|
||||
* The mapping shape is:
|
||||
* { [formFieldId]: 'firstName' | 'lastName' | 'email' | 'phone' | ... }
|
||||
*
|
||||
* Special case: a single mapping target `'name'` splits the answer
|
||||
* into firstName + lastName by the first whitespace. Useful when the
|
||||
* form has a single "Name"-field.
|
||||
*/
|
||||
export function buildContactFromAnswers(
|
||||
answers: Record<string, AnswerValue>,
|
||||
mapping: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
const contact: Record<string, unknown> = {};
|
||||
|
||||
for (const [fieldId, contactKey] 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;
|
||||
|
||||
if (contactKey === 'name') {
|
||||
const parts = str.split(/\s+/);
|
||||
contact.firstName = parts.shift() ?? str;
|
||||
if (parts.length > 0) contact.lastName = parts.join(' ');
|
||||
} else {
|
||||
contact[contactKey] = str;
|
||||
}
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply autoSync for a single response. Idempotent — checks
|
||||
* `syncedTargets` first; returns early if already synced. Mutates
|
||||
* the response row to record the new mapping.
|
||||
*
|
||||
* Throws if the form's `autoSync.target` is unsupported. Caller
|
||||
* decides whether to swallow (best-effort sweep) or surface the error
|
||||
* (interactive button click).
|
||||
*/
|
||||
export async function applyAutoSync(
|
||||
form: Form,
|
||||
response: FormResponse
|
||||
): Promise<{ synced: boolean; recordId?: string }> {
|
||||
const cfg = form.settings.autoSync;
|
||||
if (!cfg) return { synced: false };
|
||||
|
||||
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);
|
||||
if (!recordId) return { synced: false };
|
||||
|
||||
const next = [...(response.syncedTargets ?? []), { target: cfg.target, recordId }];
|
||||
await formResponseTable.update(response.id, { syncedTargets: next });
|
||||
return { synced: true, recordId };
|
||||
}
|
||||
|
||||
async function dispatchTarget(
|
||||
target: AutoSyncTarget,
|
||||
mapping: Record<string, string>,
|
||||
answers: Record<string, AnswerValue>
|
||||
): Promise<string | null> {
|
||||
switch (target) {
|
||||
case 'contacts': {
|
||||
const data = buildContactFromAnswers(answers, 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) {
|
||||
return null;
|
||||
}
|
||||
const contact = await contactsStore.createContact(data);
|
||||
return contact?.id ?? null;
|
||||
}
|
||||
case 'events':
|
||||
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)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep every response of every form with autoSync configured, applying
|
||||
* any pending sync. Idempotent — already-synced responses are skipped
|
||||
* via the syncedTargets check inside applyAutoSync.
|
||||
*
|
||||
* Vault-locked → no-op (decrypt would fail anyway). Per-response
|
||||
* failures are caught + logged so one bad mapping doesn't block the
|
||||
* rest of the queue.
|
||||
*/
|
||||
export async function runAutoSyncSweep(): Promise<{ scanned: number; synced: number }> {
|
||||
if (!isVaultUnlocked()) return { scanned: 0, synced: 0 };
|
||||
|
||||
const rawForms = (await formTable.toArray()).filter((f) => !f.deletedAt);
|
||||
if (rawForms.length === 0) return { scanned: 0, synced: 0 };
|
||||
|
||||
const decrypted = await decryptRecords('forms', rawForms);
|
||||
const forms = decrypted.map(toForm).filter((f) => f.settings?.autoSync?.target);
|
||||
if (forms.length === 0) return { scanned: 0, synced: 0 };
|
||||
|
||||
let scanned = 0;
|
||||
let synced = 0;
|
||||
|
||||
for (const form of forms) {
|
||||
const rawResponses = (await formResponseTable.where('formId').equals(form.id).toArray()).filter(
|
||||
(r) => !r.deletedAt
|
||||
);
|
||||
|
||||
// Skip responses already synced for this target (cheap check on
|
||||
// plaintext field) before paying the decrypt cost.
|
||||
const target = form.settings.autoSync!.target;
|
||||
const candidates = rawResponses.filter(
|
||||
(r) => !(r.syncedTargets ?? []).some((t) => t.target === target)
|
||||
);
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
const decryptedResponses = await decryptRecords('formResponses', candidates);
|
||||
const responses = decryptedResponses.map(toFormResponse);
|
||||
|
||||
for (const r of responses) {
|
||||
scanned += 1;
|
||||
try {
|
||||
const result = await applyAutoSync(form, r);
|
||||
if (result.synced) synced += 1;
|
||||
} catch (err) {
|
||||
console.warn(`[forms-autosync] failed for response ${r.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { scanned, synced };
|
||||
}
|
||||
|
|
@ -296,7 +296,7 @@
|
|||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<SettingsPanel settings={entry.settings} onchange={patchSettings} />
|
||||
<SettingsPanel settings={entry.settings} fields={items} onchange={patchSettings} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { useFormResponses } from '../queries';
|
||||
import { downloadResponsesCsv } from '../lib/csv';
|
||||
import { runAutoSyncSweep } from '../lib/auto-sync';
|
||||
import { RESPONSE_STATUS_LABELS } from '../types';
|
||||
import type { Form, FormResponse, ResponseStatus } from '../types';
|
||||
import ResponseDetailModal from '../components/ResponseDetailModal.svelte';
|
||||
|
|
@ -18,6 +20,35 @@
|
|||
const responses$ = useFormResponses(form.id);
|
||||
const responses = $derived(responses$.value);
|
||||
|
||||
let autoSyncSummary = $state<string | null>(null);
|
||||
|
||||
// Auto-sync sweep on mount + every time the response list updates.
|
||||
// Idempotent — runs only on responses without syncedTargets entry
|
||||
// for the configured target. See lib/auto-sync.ts.
|
||||
onMount(() => {
|
||||
void triggerAutoSync();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Recompute on response-list change. The sweep itself is a
|
||||
// no-op when nothing is unsynced.
|
||||
void responses;
|
||||
void triggerAutoSync();
|
||||
});
|
||||
|
||||
async function triggerAutoSync() {
|
||||
if (!form.settings.autoSync?.target) return;
|
||||
try {
|
||||
const result = await runAutoSyncSweep();
|
||||
if (result.synced > 0) {
|
||||
autoSyncSummary = `${result.synced} Antwort(en) automatisch synchronisiert.`;
|
||||
setTimeout(() => (autoSyncSummary = null), 4000);
|
||||
}
|
||||
} catch (err) {
|
||||
autoSyncSummary = `Auto-Sync-Fehler: ${(err as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
type FilterTab = 'all' | ResponseStatus;
|
||||
let activeTab = $state<FilterTab>('all');
|
||||
|
||||
|
|
@ -87,6 +118,10 @@
|
|||
</button>
|
||||
</header>
|
||||
|
||||
{#if autoSyncSummary}
|
||||
<p class="auto-sync-toast">{autoSyncSummary}</p>
|
||||
{/if}
|
||||
|
||||
<nav class="tabs" role="tablist">
|
||||
{#each [['all', counts.all], ['new', counts.new], ['reviewed', counts.reviewed], ['archived', counts.archived], ['spam', counts.spam]] as const as [tab, count]}
|
||||
<button
|
||||
|
|
@ -226,6 +261,16 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auto-sync-toast {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgb(20 184 166 / 0.14);
|
||||
border: 1px solid rgb(20 184 166 / 0.3);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(94 234 212);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue