mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 19:26:41 +02:00
4 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
795b39e065 |
feat(forms): M10d headless wave-cron — server-worker + private internal_meta
Echter Server-Cron für recurring forms — wave-send läuft jetzt
unabhängig von Owner-Tab-State. Bisheriger M10c webapp-side scheduler
bleibt als Belt-and-suspenders aktiv (idempotent).
Architektur:
1. **Owner-private internal_meta auf unlisted snapshots**
- Drizzle: neue jsonb-column `internal_meta` (Drizzle migration
0001_internal_meta.sql).
- public-routes.ts strippt sie strukturell — die explicit select()-
projection enthält sie nicht (recipients + sender würden sonst
via share-link leaken).
- publish-route akzeptiert sie im Body, persistiert auf insert +
update.
- ALLOWED_COLLECTIONS um 'lasts' und 'forms' erweitert (war ein
latenter Bug — formsStore.setVisibility('unlisted') hätte ohne
diese Ergänzung 400 zurückbekommen; M4b lief vermutlich nie
end-to-end durch).
2. **shared-privacy publishUnlistedSnapshot**
- PublishUnlistedOptions erweitert um optionales `internalMeta`.
Forwarded an /api/v1/unlisted/:collection/:recordId body.
3. **Webapp formsStore**
- lib/wave-mail.ts: buildFormInternalMeta(form, broadcastSettings)
baut den Owner-Private-Blob: { kind, recurrence: {frequency,
recipientEmails, lastSentAt}, sender: {fromEmail, fromName,
replyTo, legalAddress}, formMeta: {title, description} }.
Returns null wenn Voraussetzungen fehlen (kein recurrence, keine
recipients, fehlende broadcast-settings).
- stores/forms.svelte.ts: setVisibility / regenerateUnlistedToken /
setUnlistedExpiry laden broadcastSettings via Dexie + decrypt,
bauen internalMeta, übergeben an publishUnlistedSnapshot. Form
wird vor dem buildFormInternalMeta-Call dekrypted.
4. **mana-mail internal bulk-send route**
- createInternalRoutes(accountService, broadcastOrchestrator,
maxRecipients) — Signature erweitert.
- Neue POST /api/v1/internal/mail/bulk-send: gleicher Payload-shape
wie user-facing /v1/mail/bulk-send aber userId aus Body statt
JWT. X-Service-Key-gate sitzt bei /api/v1/internal/* prefix.
Audit-trail trägt principalId aus Body. Cap = 5000 (gleicher
Wert wie user-facing).
5. **apps/api forms wave-worker**
- 5-min setInterval, advisory-lock-gated (key 0x464f5257 'FORW').
- Tick: select snapshots WHERE collection='forms' AND
internal_meta IS NOT NULL AND revoked_at IS NULL. Filter auf
kind='forms-recurrence' + isWaveDue (lastSentAt + period <= now,
never-sent fires sofort). Pro fälligem snapshot: build HTML/text
mailbody (mirror webapp wave-mail-render), POST an mana-mail
internal-bulk-send mit X-Service-Key + userId, dann jsonb_set
auf internal_meta.recurrence.lastSentAt. Per-snapshot errors
werden als console.warn geloggt, Tick läuft weiter.
- Disable via FORMS_WAVE_WORKER_DISABLED=true (tests / multi-
replica deployments).
- Wired in apps/api/src/index.ts neben startArticleImportWorker().
Trade-offs:
- internal_meta wird beim setVisibility/regenerate/setExpiry frisch
aus broadcast-settings gebaut — wenn der User später broadcast-
settings ändert (zB neuer fromEmail) muss er das Form re-publishen
damit die snapshot-internal_meta aktualisiert wird. Doc-it: zukünftiger
Patch könnte ein "settings drift"-Warning ins UI surfacen.
- Worker-Update von lastSentAt geht NICHT zurück in den webapp-form
(settings.recurrence.lastSentAt ist verschlüsselt, server kann
nicht schreiben). Owner-UI zeigt ältere lastSentAt von manuellen
Sends; auto-cron-sends sind in den Server-Logs sichtbar. Future
patch: GET /api/v1/forms/:id/recurrence-status (auth) gibt das
snapshot.internal_meta zurück, UI rendert Auto-Cron-State.
- Webapp-side wave-scheduler (M10c) läuft parallel weiter — wenn
Owner-Tab offen ist, kann beides feuern. Idempotent durch
lastSentAt-check (weekly/monthly buckets), aber theoretisch könnte
double-fire passieren wenn die Calls innerhalb 1ms versetzt sind.
Real-world ignorierbar; future patch: scheduler liest jetzt
internal_meta.lastSentAt vom server-side state.
apps/api buildet (1776 modules). mana-mail buildet (523 modules).
svelte-check 0 errors in forms/. Forms-Tests 70/70 unverändert.
DB-Migration 0001_internal_meta.sql muss manuell appliziert werden
(siehe feedback memory: hand-authored SQL migrations sind nicht in
pnpm setup:db).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
6d67db48d5 |
feat(forms): M9b conversation LLM-extract — free-text → typed Antwort
Killer-Feature für den Conversation-Mode (M9): User kann auf
choice/yes_no/rating-Feldern in eigenen Worten antworten ("ich nehme
den zweiten Vorschlag" / "klar bin ich dabei" / "so 4 von 5"), ein
LLM mappt das auf die strikte Option-ID / boolean / Integer.
- apps/api/modules/forms/public-routes.ts: neuer
POST /api/v1/forms/public/:token/conversation/extract Endpoint.
Rate-limited (30/min/token + 60/min/IP — Owner-Side-Costs für haiku
trotz unauthenticated-Pfad). freeText hard-cap 1000 Zeichen.
Token-resolve via unlistedSnapshots, fieldId muss im publish-Schema
existieren. Dispatch:
- text/email/number/date: passthrough (free-text IST die Antwort)
- single_choice/multi_choice/yes_no/rating: mana-llm haiku-Call
mit field-spezifischem System-Prompt + JSON-only-Output, Parser
validiert Option-IDs gegen das Schema (Hallucination-Schutz).
Response { extracted, confidence: 'high' | 'low', alternatives? }.
confidence='low' wenn LLM unsicher → Client zeigt Warnung im
Preview-Block, User kann manuell auswählen.
- ConversationFormView: collapsible <details>"Lieber in eigenen
Worten antworten?"-Block unter den quick-reply-Buttons aller
choice/yes_no/rating-Felder. User tippt Free-Text → "Verstehen"
ruft endpoint → Preview-Karte mit der erkannten Antwort
(teal=high-confidence, amber=low-confidence) → "Übernehmen" oder
"Abbrechen". commitExtract löst setAnswerAndAdvance aus, läuft
über den selben Pfad wie quick-reply-Klick.
Schema-Validierung im Parser:
- single_choice: optionId muss in field.options sein, sonst null
- multi_choice: filtert nur valide IDs raus, Array kann leer sein
- yes_no: nur true/false/null erlaubt
- rating: round(value), bounds-check 1..ratingScale
LLM-Call:
- model claude-haiku-4-5 (cheapest)
- temperature 0 (deterministisch)
- maxTokens 200 (JSON-Output ist klein)
- Markdown-code-fence-Strip für robustes JSON-Parsing
Trade-offs:
- Public-Endpoint = ungated LLM-Spend für Form-Owner. Rate-Limits
+ freeText-Cap mitigaten Spam, aber 30 Calls/min × 200 tokens =
moderate Kosten pro Form. Owner sollte das im Hinterkopf haben.
- Confidence='low' eskaliert zur User-Sichtbarkeit, bricht aber
nicht den Flow — User kann übernehmen oder abbrechen.
Forms-Tests 61/61 unverändert (extract braucht Live-LLM für E2E,
absichtlich kein vitest-Mock). svelte-check 0 errors. apps/api
buildet (1772 modules).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
38e0ae2ff8 |
feat(forms): M10a wiederkehrende Forms — cohort-tagging + UI
Plan: docs/plans/forms-module.md M10. Erste Hälfte (Datenshape +
Submit-Stamping + ResponsesView-Filter); cron-basierter
Broadcast-Versand bleibt M10b.
- types.ts:
- FormSettings.recurrence?: { frequency: 'weekly'|'monthly',
startedAt?, sendVia? }. Im Settings-Blob mitverschlüsselt.
- LocalFormResponse.cohort?: string. Plaintext (sortier-/filter-
Feld, kein PII). FormResponse.cohort: string|null.
- queries.toFormResponse maps null durch.
- lib/cohort.ts (pure):
- computeCohort(iso, frequency) → "YYYY-WNN" (ISO-Woche, padded für
lex-sort) | "YYYY-MM". Behandelt Year-Boundary korrekt
(2027-01-01 → 2026-W53).
- cohortLabel(cohort, frequency, now=new Date()) → "Diese Woche" /
"Letzte Woche" / "KW NN / YYYY" für weekly; "Dieser Monat" /
"Letzter Monat" / "Monatsname YYYY" für monthly. Falsche Keys
fallen auf Roh-Wert zurück (inkl. Bounds-Check 1-12 für month).
- sortCohortsDesc(): immutable, lex-sort newest-first.
- 15/15 Vitest-Cases (compute, label-current/prev/older, malformed,
sort, no-mutation).
- buildFormBlob (data/unlisted/resolvers.ts) zieht recurrence.frequency
in den Public-Snapshot. Frequenz ist nicht-sensitive Metadaten —
kann öffentlich diskoverabel sein, der Server braucht es zum
cohort-stamping.
- apps/api/src/modules/forms/public-routes.ts:
- Inline computeCohort (Mirror der pure-Funktion — apps/api kann
apps/mana nicht importieren).
- Bei jeder Submission: wenn snapshot.recurrence.frequency gesetzt,
stamp data.cohort = computeCohort(submittedAt, freq). Cohort
landet im sync_changes-Insert und reist zum Owner-Client.
- errorResponse-Aufrufe: { code, field } → { code, details: { field } }
weil der Hono-helper die details-Struktur erzwingt (M3b
type-safety-fix mitgenommen).
- SettingsPanel: Recurrence-Block mit Dropdown (Einmalig / Wöchentlich
/ Monatlich) + Hint-Erklärung. Setzt startedAt automatisch beim
Aktivieren.
- ResponsesView: cohort-chip-bar oberhalb der Status-Tabs. Zeigt
"Alle Wellen" + ein Chip pro distincter cohort (newest-first), je
mit Anzahl. Klick filtert die responses-Liste — alle bestehenden
Status-Counts berechnen sich auf den cohort-gefilterten Set, also
CSV-Export + Detail-Modal-Status-Pills bleiben konsistent.
- 8 neue i18n-Keys × 5 Locales (forms.builder.recurrence.* +
forms.responses.cohort.all). i18n-parity 6483 keys aligned.
Tests: 41/41 forms-tests grün (5 csv + 11 branching + 10 auto-sync +
15 cohort). svelte-check 0 errors. apps/api buildet.
M10b open: cron-worker in mana-ai oder mana-notify, der
recurrence-Forms scant + Share-Link via broadcasts an die
Recipients-Liste sendet. Schemata stehen, Pipeline fehlt noch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e99fea1938 |
feat(forms): M3b public-submit endpoint — schließt den Public-Loop
Server-side Public-Submit für unlisted-shared Forms (Plan
docs/plans/forms-module.md M3.b):
- POST /api/v1/forms/public/:token/submit (apps/api):
- Token-resolve via unlistedSnapshots-Tabelle (eq, limit 1).
- Hard-blocks: 404 unbekannt, 410 revoked/expired, 400 wrong
collection, 400 invalid JSON.
- Schema-validiert serverseitig: filtert eingehende answers auf
field-IDs aus dem Snapshot (anti-injection), prüft required
Antwort-Felder + required consent-Felder.
- Hashed IP (SHA-256, hex) als Anti-Spam-Fingerprint, plus
User-Agent + Referer truncated, in submitterMeta.
- Schreibt sync_changes(table='formResponses', op='insert', data,
field_meta, actor='system:forms-public-submit', origin='system')
in einer Transaktion mit set_config('app.current_user_id') für
RLS — mirror vom articles import-extractor.
- Token-scoped rate-limit (10/min) + IP-scoped (30/min), gleiche
Architektur wie unlisted/public-routes.
- Returns { ok: true, responseId, submittedAt }.
- SharedFormView (apps/mana/apps/web): handleSubmit POSTet jetzt an
${PUBLIC_MANA_API_URL || origin:3060}/api/v1/forms/public/:token/submit.
Submitting-State (Disabled-Button + "Sende ..."), Error-Block bei
Server-Fehlern, Submitter-Block (Name + Email, beide optional). Der
DEV-Hinweis ist weg.
Encryption: server speichert plaintext im sync_changes-Blob. Der
Client-side Decrypt-Path ist no-op für non-encrypted shapes
(record-helpers.ts:241), also kein Crash beim Pull. Encrypted-at-rest
für public submissions ist M6 ZK-Mode (eigener per-Form-Key der
Form-Owner client-seitig hält).
Mounted pre-auth in apps/api/src/index.ts neben unlisted/public.
apps/api buildet (1769 modules, no TS errors). svelte-check:
0 errors in forms/. Forms-Modul ist End-to-End nutzbar — User legt
Form an, publisht, setzt visibility=unlisted, kopiert Share-Link,
externe Person füllt aus + sendet, Antwort landet im
ResponsesView des Owners.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|