Commit graph

927 commits

Author SHA1 Message Date
Till JS
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>
2026-05-06 17:18:05 +02:00
Till JS
82dbfe6ee7 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>
2026-05-06 16:27:40 +02:00
Till JS
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>
2026-05-06 15:31:25 +02:00
Till JS
c1ed45e574 feat(forms): M9 form-as-conversation — Typeform-Chat-Render
Public-Form-Variante als linearer Chat-Flow (M9, KF4 aus dem Plan).
Owner wählt im Builder zwischen "klassisch" (alle Felder
gleichzeitig, M4b-View) und "conversation" (eine Frage nach der
anderen, mobile-friendly).

LLM-gestützte free-text → typed-Antwort-Extraktion (z.B. "Ich nehme
den zweiten Vorschlag" → option-id) bleibt M9b — die jetzige
Implementierung nutzt typed widgets pro Field-Type für einen
deterministischen ersten Wurf.

- types.ts: FormSettings.experience: 'classic' | 'conversation'
  (default 'classic'). Reist im Settings-Blob mitverschlüsselt.
- data/unlisted/resolvers.ts: buildFormBlob whitelistet experience
  ins public-snapshot — nur ein Enum, kein PII.
- SharedFormView (M4b) bleibt der classic-Renderer.
- ConversationFormView (neu, ~600 Zeilen):
  - Linear: stepIndex zeigt durch das Visible-Subset von
    resolveVisibleFields (gleicher branching-resolver wie classic).
  - Pro Step: question-bubble + Field-Type-spezifischer Widget:
    short_text/long_text/email/number → Free-Text-Input mit
    Enter-Submit, date → datepicker, yes_no → 2 Quick-Reply-Buttons,
    rating → Skala-Buttons, single_choice → vertikale
    Quick-Reply-Liste, multi_choice → Toggle-Chips + "Weiter",
    section → "Verstanden"-Step, consent → Yes(/Nein optional).
  - Answer-Bubble nach Submit; "← Vorherige" droppt das letzte
    Q/A-Pair und löscht die Antwort, damit der branching-resolver
    den nächsten Step neu berechnet.
  - Final-Step: Submitter-Name+Email (optional) + bestehender
    POST /api/v1/forms/public/:token/submit.
  - Progress-Bar oben, "via Mana Forms"-Footer.
- routes/share/[token]/+page.svelte: dispatched bei
  collection='forms' auf experience-Wert — 'conversation' →
  ConversationFormView, sonst SharedFormView.
- SettingsPanel: dropdown unter den Anonymous-Toggle, dt./eng./es./
  fr./it. (15 neue i18n-Keys × 5 Locales = 6498 keys aligned).

Trade-offs:
- Branching reagiert pro-step: wenn der User auf einer späteren Frage
  zurückgeht und die Quelle einer Hide-Regel ändert, fällt der
  zwischenzeitlich gerenderte Pfad weg — eventuell taucht eine neue
  Frage als "next" auf. Dokumentiert als linearer "tree-walk" statt
  WYSIWYG-Snapshot, üblich für Typeform-Klone.
- Ohne LLM-Extraction (M9b) sind die Quick-Replies nicht fluide; das
  ist intent: deterministic > magical for first ship.

Forms-Tests 61/61. svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:22:56 +02:00
Till JS
7d8e562091 feat(forms): M10c auto-scheduler + mana-mail bulk-send
Headless wave-send während die Mana-Tab offen ist (M10c). Echter
Server-Cron (mana-ai oder mana-notify) bleibt M10d.

- lib/wave-mail.ts: sendWaveViaBulkMail POSTet an
  /api/v1/mail/bulk-send mit form-derived payload (subject =
  "{title} — {cohort}", htmlBody mit Inline-CSS + share-link-Button +
  Impressum + Abmelden-Footer, textBody plain). campaignId =
  form-{formId}-{cohort} (idempotent über Retries). Wirft
  WavePreconditionError wenn fromEmail/fromName/legalAddress fehlen
  oder Empfänger leer sind — Caller fällt auf mailto-Bridge zurück.
- lib/wave-scheduler.ts: singleton setInterval (5 min,
  Page-visibility-aware — pausiert bei hidden), Tick scant formTable,
  dekrypt-aware, filtert published+token+recurrence+recipients+due,
  ruft sendWaveViaBulkMail + markWaveSent. Wirft nicht — per-form
  errors werden als console.warn geloggt, Schedule läuft weiter.
  Initial-tick 30s nach start damit Montag-Morgen-Welle nicht
  5 Minuten warten muss. start/stop idempotent.
- BuilderView.sendWave: versucht erst bulk-send (wenn
  broadcasts-Settings configured = defaultFromEmail + legalAddress),
  fällt auf mailto-Bridge zurück (M10b) wenn precondition fehlt.
  waveError-state für non-precondition-Fehler. Confirm-Dialog hat
  jetzt zwei Texte (confirmBulk vs confirmSend).
- (app)/+layout.svelte: startWaveScheduler() neben startMissionTick()
  beim Auth-Ready, stopWaveScheduler() im onDestroy.
- 5 neue i18n-Keys × 5 Locales (forms.builder.recurrence.confirmBulk).
  Parity 6495.

Trade-offs:
- Auto-Tick nur während Tab offen — headless Cron via mana-ai-Mission
  oder mana-notify-Worker bleibt M10d.
- Bulk-send bypasst die Campaign-Pipeline der broadcasts (kein
  Audience-Filter, kein Rich-Editor) — ist Absicht für Forms-Wellen
  als kurze transactional notifications.
- DSGVO: Impressum + Abmelden-Footer ({{unsubscribe_url}} wird vom
  Orchestrator pro Empfänger ersetzt) sind Pflicht via
  WavePreconditionError; Mailto-Fallback hat das nicht — User-Risk.

Forms-Tests 61/61 unverändert. svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:15:12 +02:00
Till JS
664b8241d0 feat(forms): M10b wave-send — Empfänger + Manueller Trigger + Due-Banner
Erste nutzbare Versand-Schicht für wiederkehrende Forms (M10b).
Vollautomatisches Cron via mana-ai/mana-notify bleibt M10c — die
Owner-action-Pipeline funktioniert standalone und nutzt den
existierenden mailto:-Pfad als Bridge.

- types.ts: RecurrenceConfig erweitert um `recipientEmails?: string[]`
  (max 50, mailto-URL-realistisch) und `lastSentAt?: string`.
- lib/wave.ts (pure):
  - nextWaveDueAt(recurrence): lastSentAt + 7d (weekly) oder
    +1 month UTC (monthly). Never-sent → startedAt oder Epoch.
  - isWaveDue(recurrence, now): boundary-inclusive (= ist auch fällig).
  - buildWaveMailto({recipients, subject, body}): URL-encoded
    mailto:?bcc=...&subject=...&body=... Keine BCC wenn empty.
  - parseRecipientEmails(raw): newline/comma/semicolon-getrennt,
    Email-Regex-validiert, case-insensitive deduped (erste Casing
    bleibt). Drops invalid silent.
- lib/wave.spec.ts: 20/20 grün — month-end-overflow, boundary-instant,
  never-sent, dedup, mixed-separators.
- formsStore.markWaveSent(id, sentAt?): liest current settings,
  patch-tt lastSentAt, encrypted-aware update (settings ist
  encrypted-blob).
- SettingsPanel: bei aktiver recurrence Empfänger-Textarea (commit on
  blur via parseRecipientEmails, slice 50, count-feedback) +
  lastSent-Hint.
- BuilderView (visibility-section): wave-block mit fällig-Banner
  (orange wenn isWaveDue) oder nextWaveAt-Hint, "Welle jetzt
  senden"-Button (disabled bis recurrence + unlistedToken +
  recipients alle stimmen). Click → confirm → buildWaveMailto +
  window.open + markWaveSent. Subject + Body via i18n-Keys.
- 13 neue i18n-Keys × 5 Locales (recipientsLabel/Count, lastSent,
  waveDue, nextWaveAt, sendNow, needsUnlisted/Recipients,
  mailSubject/Body, confirmSend). Parity 6494.

Total Forms-Tests jetzt 61/61 (5 csv + 11 branching + 10 auto-sync +
15 cohort + 20 wave). svelte-check 0 errors.

Use-Case: Wöchentlicher Team-Pulse-Check. Recurrence='weekly' setzen,
3 Team-Emails ins Textarea, am Montag-Morgen das fällig-Banner
sehen, "Welle jetzt senden" → Mail-Programm öffnet sich mit
BCC-Liste + Share-Link. Antworten kommen mit cohort='2026-W19' rein,
ResponsesView gruppiert sie.

M10c open: Cron-Worker für headless wave-send via mana-mail
bulk-send. Owner-tab muss heute offen sein, damit der Send-Klick
fällt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:56:28 +02:00
Till JS
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>
2026-05-06 14:09:55 +02:00
Till JS
cb9d79d2c9 feat(articles): finale polish — Help-Eintrag + intentional-console + 5-Locale i18n (#16,#10,#17)
#16 — Help-content entry for articles
   The articles module had no entry in `app-registry/help-content.ts`
   so the (?) icon in ModuleShell rendered an empty body. Added
   description + 9 features + 4 tips covering Reader-View, Highlights,
   Bookmarklet, Share-Target, and the new bulk-import flow with the
   "Erneut speichern" rescue path for cookie-walled hits.

#10 — Console statements marked intentional
   The 5 console.log/warn/error calls in import-worker.ts (boot,
   tick errors, GC summary, stale-recovery sweep) were ESLint
   warnings. They're intentional operational logs — same pattern as
   services/mana-ai/src/cron/tick.ts. Added file-level
   `/* eslint-disable no-console */` with a comment explaining the
   pattern + that structured signal lives in Prometheus counters.

#17 — Full 5-locale i18n for the bulk-import UI
   New `articles.import` namespace with 50 keys covering the
   BulkImportForm, JobsList, JobDetailView, and AddUrlForm bulk-link.
   All five locales translated by hand:
     - de.json (canonical, mirrors the original hardcoded German)
     - en.json (English)
     - fr.json (French — bookmarklet → "bookmarklet HTML du navigateur")
     - it.json (Italian — bookmarklet → "bookmarklet HTML del browser")
     - es.json (Spanish — bookmarklet → "bookmarklet HTML del navegador")
   Plural-aware `consent_hint_body` uses ICU plural format
   (`{n, plural, one {…} other {…}}`) so single-vs-multiple article
   counts read naturally in each language.
   The consent-hint sentence is split into 3 keys (body/link/after-link)
   so the link text appears mid-sentence rather than tacked on after.
   Components converted to `$_('articles.import.*')` everywhere — no
   remaining hardcoded strings in the bulk-import UI.

i18n parity validator: 76 namespaces × 5 locales — 6477 canonical
keys, all aligned. validate:i18n-hardcoded baseline unchanged for
articles files (broadcasts/notes/timeline failures are user-WIP).

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:39:50 +02:00
Till JS
48bd09188c feat(forms): FormsWidget — Workbench-Karte mit Stats + letzte Forms
Heimstart-Karte für das Forms-Modul, parallele zu BroadcastsWidget /
InvoicesOpenWidget:

- modules/forms/widgets/FormsWidget.svelte: 3-Spalten-Stats
  (veröffentlicht / Entwurf / Antworten total + "+N/7T" delta für
  letzte 7 Tage), bis zu 2 zuletzt aktualisierte Forms mit
  Status-Punkt (grün=published, grau=sonst) + Response-Count +
  relative-Zeit, "+N weitere"-Link wenn mehr als 2 Forms existieren.
  Empty-State mit "+ Erstes Formular bauen". Live aus Dexie via 2
  parallele liveQuery-Subs (forms + formResponses).
- types/dashboard.ts: WidgetType-Union erweitert um 'forms';
  WIDGET_REGISTRY-Eintrag mit defaultSize 'medium' + 📋-Icon.
- components/dashboard/widget-registry.ts: FormsWidget importiert +
  in widgetComponents map registriert.
- 5 Locales × 2 dashboard-Keys (forms.title + forms.description).

App-Registry-Eintrag für /forms in app-registry/apps.ts existiert
bereits (Parallel-Session). FormsWidget ist die _aggregierte_
Heimstart-Variante; der app-registry-Eintrag mountet die ListView
direkt als Modul-Card.

i18n-parity 6417 keys aligned. svelte-check 0 errors in
modules/forms/widgets/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:50:16 +02:00
Till JS
e37c008a7a chore(articles): polish pass — schema cleanup, MAX cap, filters, docs (#8,#9,#13,#15,#18,#20)
Polish-pass on top of the bulk-import rollout. Five contained items.

#8 + #9 — Dexie v60 schema cleanup
   - Drop articleImportJobs.leasedBy + .leasedUntil. They were defined
     on the original v57 schema as a soft-lease handshake, but the
     worker uses pg_try_advisory_xact_lock and never wrote them.
     Local-* type + projection row stripped.
   - Drop the standalone `state` index on articleImportItems.
     [jobId+state] covers the worker's hot query; the state-solo
     index had no call site.
   Both changes lossless — Dexie just removes the column declarations
   from new rows; existing rows still carry the dead nulls (zombies)
   until the next full row-rewrite. Not worth a hard migration for
   two never-written columns.

#15 — MAX_URLS_PER_JOB hard cap (200)
   articleImportsStore.createJob() throws if the URL list exceeds the
   cap. BulkImportForm surfaces the limit in the live counter chip
   and disables the submit when over. The worker can chew through any
   N, but at high counts the UI gets unwieldy (no virtualisation) and
   wall-clock duration climbs into multi-hour. 200 is a pragmatic
   ceiling — Pocket-export dumps average 50–150.

#13 — Filter-Tabs in JobsList
   Pill-style tabs above the list: Alle / Aktiv / Fertig / Mit Fehlern,
   each with the row count. Disabled when the bucket is empty so the
   user only sees actionable filters. The "Mit Fehlern" filter
   (errorCount > 0) is the most valuable for triage.

#18 — apps/mana/CLAUDE.md
   - Articles row added to the Tool Coverage table (5 propose +
     1 auto, including the new auto-policy import_articles_from_urls).
   - New "Articles bulk-import" section after the AI Workbench part:
     pipeline diagram, table list, actor + metrics + cap pointers.

#20 — ARTICLES_IMPORT_WORKER_DISABLED env var documented
   New row under "Mana API — Articles Bulk-Import Worker" in
   docs/ENVIRONMENT_VARIABLES.md.

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:42:46 +02:00
Till JS
ace1b706e6 feat(forms): M8 website-block — formEmbed bindet Mana-Formulare ein
Neuer Block-Type `formEmbed` im Website-Builder
(docs/plans/forms-module.md M8):

- @mana/website-blocks/src/formEmbed/:
  - schema.ts: FormEmbedSchema mit token (32-char base64url) +
    titleOverride + optional resolved-Block (formTitle, fields,
    branching, settings.{submitButtonLabel, successMessage}).
    FormFieldEmbedSchema duplicated leichtgewichtig statt cross-
    package import — website-blocks bleibt self-contained.
  - FormEmbed.svelte: edit/preview rendert Placeholder-Card mit
    Token-Snippet und resolved-Status; public rendert die kompletten
    11 Field-Types inkl. Live-Branching-aware-Render. Submitter-
    Block (Name+Email optional). Submit POSTet an
    /api/v1/forms/public/:token/submit. Lazy-Fallback fetcht
    /api/v1/unlisted/public/:token wenn die publish-resolver-blob
    fehlt. Bot-Honeypot bleibt M8-Polish.
  - FormEmbedInspector.svelte: Token-Input mit base64url-Validierung
    bei blur, optional titleOverride, resolved-Card mit
    Field-Count + Logik-Regel-Count.
- BLOCK_SPECS + BLOCK_SCHEMAS + BLOCK_DEFAULTS um formEmbed
  erweitert. schemas.test.ts erwartet jetzt 12 Block-Types.
- apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts:
  resolveFormEmbed scant formTable nach unlistedToken (linear scan
  ist günstig bei <100 forms pro user, kein Index nötig), dekrypted,
  validiert published-status, gibt resolved-Block zurück.
- publish.ts.resolveEmbedsInTree erweitert um formEmbed-Branch — ruft
  resolveFormEmbed parallel zu resolveEmbed (moduleEmbed) im selben
  Walk.

Trade-offs:
- Token statt formId: bei Token-Rotation (M4b) muss der User den Block
  neu konfigurieren. Der formEmbed-Block-Resolver erkennt das + setzt
  resolved.error; public-Renderer fällt auf lazy-fetch zurück.
- Plaintext stored: das resolved-Blob landet als plaintext im
  public-snapshot, gleiches Trust-Modell wie moduleEmbed (öffentliche
  Website per Definition).

Tests: website-blocks 50/50 grün (12 schema-block-types + per-type
defaults validation). svelte-check 0 errors. forms 26/26 unverändert.

Use-Case: Vereins-Sommerfest. User legt /forms/anmeldung an,
publisht, setzt unlisted, kopiert Token. Im Website-Builder fügt er
einen formEmbed-Block auf der Event-Seite ein, paste Token → bei
Publish wird der Form-Schema inlined → Besucher submitten direkt
auf der Vereins-Website.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:38:28 +02:00
Till JS
57b7a43147 feat(forms): M7b auto-sync zu events — RSVP-Pipeline für Anmeldungen
Erweiterung von M7a um events als zweites Auto-Sync-Target
(docs/plans/forms-module.md M7 — Teil 2):

- AutoSyncConfig erweitert mit optionalem `targetId` — speichert die
  eventId, zu der RSVPs angelegt werden sollen.
- lib/auto-sync.ts:
  - buildEventGuestFromAnswers (pure): mapping form-field-id →
    guest-key (name/email/phone/note/plusOnes). plusOnes wird zu
    non-negativem Integer gecoerct, Negative + non-numeric werden
    silently gedroppt.
  - dispatchTarget('events'): wirft wenn targetId fehlt; ruft
    eventGuestsStore.addGuest mit rsvpStatus='yes' (Form-Submit
    impliziert Zusage). Mindest-Voraussetzung: name muss gesetzt
    sein, sonst null (verhindert leere Gast-Zeilen).
  - feedback / library / space_member bleiben strukturell mit
    "noch nicht implementiert"-throw — feedback ist eigene Domain
    (kein Dexie), library + space_member brauchen mehr Architektur.
- lib/auto-sync.spec.ts: 4 neue Tests (direct mapping, plusOnes
  parsing, empty/null skip, unknown-key drop). Total Forms-Tests
  jetzt 26/26.
- SettingsPanel: SUPPORTED_TARGETS auf [contacts, events] erweitert.
  Bei target='events': event-picker dropdown (nutzt useAllEvents),
  Hint wenn kein Event gewählt, Mapping-Grid mit GuestKey-Optionen
  (name, email, phone, note, Begleitpersonen). Target-Switch löscht
  altes mapping (verschiedene Targets, verschiedene Keys).
- 13 neue i18n-Keys × 5 Locales (autoSync.targetEvents,
  eventsHint, eventPicker*, eventNeeded, guestKey.*).

Use-Case: Vereins-Sommerfest. Form mit "Wie heißt du? / Email /
Bringst du jemanden mit?" → autoSync zu Event "Sommerfest 2026".
Submit erzeugt automatisch Gast mit RSVP=yes. Kein manuelles
Übertragen mehr nötig — direkter Pipeline-Vorteil gegenüber
Typeform/Tally.

svelte-check 0 errors. i18n-parity 6415 keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:17:37 +02:00
Till JS
e8774fc233 test(articles): worker rollup + field-meta + consent-wall + recovery UI (#6,#14)
#6 — Worker test coverage on the deterministic helpers
   Three new bun-test files in apps/api/src/modules/articles/:
     - field-meta.test.ts (6 tests): pins down the legacy-vs-F3 fix
       so it can never regress silently — including the regression
       check from the live-test-found bug (string vs object compare
       across both shapes evaluates correctly).
     - consent-wall.test.ts (8 tests): the heuristic we extracted
       in #4. German + English vocab, wordcount threshold + the
       boundary case, case-insensitivity.
     - import-worker.test.ts (5 tests): countByState rollup. Pins
       down the consent-wall-counts-as-saved semantics so the
       progress bar doesn't off-by-one and allTerminal stays correct.
   Total 19 bun tests, all green.
   countByState + StateCounts exported (test-only access).

#14 — Consent-wall recovery UI in JobDetailView
   Bulk-import items that hit a cookie-wand land as state='consent-wall'
   with the teaser saved. Before this commit there was no UX path to
   "rescue" them other than navigating to the article and re-saving
   manually. Now:
     - Job-level hint banner appears when warningCount > 0,
       explaining the cookie-wand semantics + linking to
       /articles/settings (where the v2 bookmarklet lives).
     - Per-item action group on consent-wall rows: "Teaser ansehen"
       (open existing article) + "Erneut speichern" (deep-link to
       /articles/add?source=bookmarklet&url=… so the bookmarklet's
       postMessage handshake has the URL pre-populated).

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:30:04 +02:00
Till JS
7f805d9da2 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>
2026-04-29 01:07:22 +02:00
Till JS
93545f8516 chore: drop who + kontext MANA_APPS entries to match earlier extractions
Two cleanup follow-ups that the parallel sessions which extracted these
modules left behind, surfaced by the route-drift test added in 6d193a9fa:

who — `chore: extract who module into standalone repo` (a3eedfc87) +
follow-up cleanup (f076d9345) removed `lib/modules/who/` and the
workbench `registerApp({ id: 'who' })` block, but the broken `/who/+page`
and `/who/play/[gameId]/+page` routes still imported the deleted module
and the MANA_APPS entry, APP_ICONS icon, categories.ts mapping and
help-content block were still in place. Drop all five.

kontext — `feat(notes): isSpaceContext flag replaces kontext module
(Option B)` (8fbdc6db7) replaced the kontext module with a per-note
`isSpaceContext` flag in the notes module. The MANA_APPS entry I added
in 6d193a9fa and the matching APP_ICONS entry are now both stale —
there is no `kontext` route, no module, no registerApp. Drop them.

Verification: `registry.spec.ts` 4/4 green, `svelte-check src/lib`
0 errors / 5 warnings (pre-existing in other files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:04:34 +02:00
Till JS
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>
2026-04-29 00:44:42 +02:00
Till JS
0d85d7c36b feat(forms): M5 AI tools — 7 tools im AI_TOOL_CATALOG
AI-Zugriff aufs Forms-Modul (docs/plans/forms-module.md M5):

Propose (User-Approval erforderlich):
- forms_create — neues Formular im Draft-Status, optional mit Feldern.
  Field-Shape im params-Array: { type, label, required?, helpText?,
  options?: [{label}] }. Type-Enum aus dem 11-Typ-Katalog. Planner
  kann z.B. "Vereins-Anmeldung" mit Name+Email+Position+Trikotgröße
  in einem Aufruf bauen.
- forms_add_field — Feld ans Ende anhängen, Reorder bleibt User
  vorbehalten (Drag im Builder).
- forms_publish — draft → published. Wirft, wenn Form keine Antwort-
  felder hat (nur section/consent würde Public-Submit sinnlos machen).
- forms_close — published → closed, Antworten + Share-Link bleiben.

Auto (silent execution während Planner-Reasoning):
- forms_list — Metadaten (id, title, status, fieldCount, responseCount,
  visibility), Status-Filter optional, Default-Limit 50. VaultLocked-
  aware → klare Fehlermeldung statt Crash.
- forms_get_responses — Aggregat-Stats: per Form ein
  ResponseAggregate {totalCount, statusCounts, choiceHistograms,
  textSamples, numericStats}. Choice-Felder mit Option-Label-Mapping
  (nicht Option-IDs), Text-Felder als Sample-Array (cap 50, default).
- forms_summarize_responses — gleicher Aggregator mit window-filter
  (sinceDays) und höherem Sample-Cap (200), als Daten-Vorlage für
  LLM-Clustering im nächsten Planner-Schritt. Augur-style: keine
  eigene LLM-Roundtrip, der Planner formuliert Themes selbst.

Verdrahtung:
- AI_TOOL_CATALOG in @mana/shared-ai mit 7 ToolSchema-Einträgen +
  defaultPolicy.
- ModuleTool-Implementierungen in modules/forms/tools.ts mit
  scopedForModule für Space-Awareness, decryptRecords für encrypted-
  table-Reads, VaultLocked-Handling.
- Registriert in data/tools/init.ts.

Validierung:
- mana-ai planner-drift test: 4/4 grün — alle 4 propose-Tools
  (forms_create/add_field/publish/close) im SERVER_TOOLS-Subset.
- svelte-check 0 errors in forms/.
- Forms unit tests: 16/16 (csv + branching) unverändert grün.

Tools-executor.test.ts ist pre-existing rot wegen
$lib/modules/context-Drift in module-registry.ts (Parallel-Session-
WIP, nicht durch mich).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:33:55 +02:00
Till JS
a295894ca6 chore: drop legacy context module files (companion to acb737e25)
Companion deletion sweep — acb737e25 removed all the *registry refs*
to the legacy `context` module, but its source files were still on
disk on main (because the original deletion in d3e2e73ca on the
articles-bulk-import branch was bundled with unrelated photon /
broadcast-rename work and never landed on main). Dropping them now
so the consolidation is self-contained:

- apps/mana/apps/web/src/lib/modules/context/ — entire module dir
- apps/mana/apps/web/src/routes/(app)/context/ — page routes
- apps/mana/apps/web/src/lib/components/dashboard/widgets/ContextDocsWidget.svelte
- apps/mana/apps/web/src/lib/i18n/locales/context/{de,en,es,fr,it}.json
- packages/shared-branding/src/logos/ContextLogo.svelte

Verified: svelte-check + tsc --noEmit both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:23:10 +02:00
Till JS
1815139dc1 chore: drop context module — registry refs, schema, AI route, AppId
The context module's UI + Dexie tables + i18n bundle were already
removed in d3e2e73ca. This follow-up cleans up everything else that
still referenced it:

- API: rename POST /api/v1/context/import-url → /api/v1/kontext/import-url
  (the kontext singleton was the only consumer); drop the unused
  /ai/generate + /ai/estimate endpoints; rename the credit-op label
  AI_CONTEXT_IMPORT_URL → KONTEXT_IMPORT_URL; drop AI_CONTEXT_GENERATION
  from packages/credits.
- Web: drop registerApp + File icon import from app-registry/apps.ts;
  drop contextModuleConfig from data/module-registry.ts (+ snapshot test);
  drop useRecentDocuments + useSpaces from cross-app-queries.ts; drop
  ContextDocsWidget from widget-registry + dashboard.svelte.ts +
  types/dashboard{,.test}.ts; drop dashboard.widgets.context from all 5
  dashboard locales; drop context entries from hooks.server allowlist,
  splitscreen registry, observatory mockData, spiral collect, crypto
  registry + plaintext-allowlist.
- Dexie: remove documents/contextSpaces/documentTags from v1, v31, v53
  stores blocks; add v57 dropping the three tables on local dev DBs
  that already ran an earlier schema.
- Shared-branding: drop 'context' from AppId union, APP_BRANDING,
  MANA_APPS, APP_ICONS (+ contextSvg), ContextLogo.svelte (+ logos
  barrel re-export).
- Spiral-DB: drop context: 10 from MANA_APP_INDEX (slot now free).
- i18n hardcoded-string baseline: drop 5 context routes/files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:20:04 +02:00
Till JS
8fbdc6db77 feat(notes): isSpaceContext flag replaces kontext module (Option B)
Retire the kontext module entirely; the per-Space standing-context
document is now a regular Note flagged with `isSpaceContext: true`.
Daily use ("URL → Notiz") moves to the notes module as a first-class
action; the same primitive is reused by the (planned) Brand/Firma-Space
onboarding wizard to seed a Space-context Note from a URL.

Why: kontext was inconsistent — its UI was a URL-crawler that wrote
to userContext.freeform (profile module), while its kontextDoc table
+ AI-Mission-Runner auto-injection was a write-only shell with no
real editor. One concept (Notes) now carries both ad-hoc noting and
Space-context, with mutex (max 1 flagged Note per Space).

Notes module:
- types: add `isSpaceContext?: boolean` to LocalNote + Note
- queries: add `useSpaceContextNote()` (the active Space's flagged note)
- store: `markAsSpaceContext(id | null)` with mutex sweep across Space
- ListView: "Aus URL importieren" inline form (URL + crawl-mode +
  KI-Zusammenfassung toggle); "Als Space-Kontext markieren" /
  "Space-Kontext lösen" context-menu item; ★-Badge on flagged notes
- new api.ts: `crawlUrl()` client for POST /api/v1/notes/import-url

Notes API (apps/api):
- new modules/notes/routes.ts with /import-url (ported from kontext;
  same crawler + LLM summary pipeline, NOTES_IMPORT_URL credit op)
- mount at /api/v1/notes; add 'notes' to RESOURCE_MODULES (beta+ tier)
- delete modules/context (UI-less /ai/generate + /ai/estimate had no
  consumers; /import-url moved to notes)
- packages/credits: rename AI_CONTEXT_GENERATION → NOTES_IMPORT_URL

AI Mission Runner:
- default-resolvers: drop kontextResolver + kontextIndexer; the
  notesIndexer flags `isSpaceContext` notes with "★ " prefix and
  bubbles them to the top of the picker
- writing reference-resolver: `kind: 'kontext'` now reads the flagged
  Note via scope-scan instead of the kontextDoc table; tests updated
- writing ReferencePicker: useSpaceContextNote replaces useKontextDoc
- AiDebugBlock + MissionGrantDialog + ai-missions ListView: drop
  'kontextDoc' from ENCRYPTED_SERVER_TABLES set
- ai-agents ListView: drop 'kontext' from POLICY_MODULES

Profile module:
- ContextFreeform.svelte: switch import from kontext/api to notes/api
  (the URL-crawl is the same primitive; it still writes to
  userContext.freeform — only the import path changed)

Dexie:
- v58: notes index gains `isSpaceContext`; kontextDoc table dropped

Kontext module deletion:
- delete apps/mana/apps/web/src/lib/modules/kontext/ entirely
- delete (app)/kontext/ route
- drop registerApp + Scroll icon from app-registry/apps.ts
- drop kontext entry from help-content
- drop kontextModuleConfig from data/module-registry.ts
- drop kontextDoc from crypto registry

mana-auth:
- bootstrap-singletons: drop bootstrapSpaceSingletons function entirely
  (kontextDoc was the only per-Space singleton); userContext bootstrap
  unchanged
- better-auth.config: drop kontextDoc bootstrap call from personal-space
  hook + organizationHooks.afterCreateOrganization
- me-bootstrap: drop per-space bootstrap loop; response shape kept
  (always-empty `spaces: {}`) for backwards-compat with older clients

Note: the still-existing legacy `context` module (CMS-style docs/spaces,
unrelated to kontext) is left in place; its cleanup landed on the
articles-bulk-import branch and is out of scope for this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:32 +02:00
Till JS
054b9e5beb fix(articles): import-projection accepts F3 + legacy field_meta shapes
Live-test caught it: the worker projects sync_changes via field-level
LWW, comparing `field_meta[k]` directly. But field_meta is two-shaped
on the wire:

  - Legacy plaintext writes:   { state: '2026-04-28T…' }
  - Field-meta-overhaul writes: { state: { at, actor, origin } }

The naive `rowFM[k] >= localTime` worked for the all-legacy case, but
once a client write (legacy string) followed a worker write (F3
object), the comparison evaluated `'2026-04-28T…' >= '[object …]'`
and the projection silently kept the older value. Live symptom: an
item that was correctly flipped to 'saved' on the client was reported
back as 'extracted' by the projection.

Fix: `fieldMetaTime()` helper that pulls the ISO string out of either
shape; both write paths now compare apples-to-apples.

Verified end-to-end:
  - Synthetic job + item written into sync_changes
  - runTickOnce() → claim → extractFromUrl(example.com) → pickup row
    with title='Example Domain', wordCount=16, actor=
    system:articles-import-worker
  - Item transitions pending → extracting → extracted
  - Simulated client write 'saved'
  - Next tick rolls counters: savedCount 0→1, status running→done,
    finishedAt stamped

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:43:14 +02:00
Till JS
18f13e19b2 feat(forms): M4b visibility + unlisted-sharing + public render
Form-Sharing-Pipeline (docs/plans/forms-module.md M4 — Teil 2):

- formsStore.setVisibility(id, level): private/space/unlisted-Wechsel.
  Bei `unlisted` wird publishUnlistedSnapshot mit dem Form-Schema-Blob
  aufgerufen, Token + Expiry landen am LocalForm. Setze Status auf
  'published' Voraussetzung — sonst klare Fehlermeldung mit Hinweis.
- regenerateUnlistedToken: Token-Rotation für leak-Verdacht
  (revoke + neu publish, expiry beibehalten).
- setUnlistedExpiry: TTL-Update mit re-publish.
- buildFormBlob in data/unlisted/resolvers.ts mit Whitelist
  title/description/fields/branching + nur submitButtonLabel +
  successMessage aus settings. Hard-blocks: nicht-published Forms +
  deletedAt → RecordNotFoundError. Server-side Settings (requireEmail,
  anonymous, zkMode, autoSync, responseLimit, closedAt, responsesPublic)
  bleiben strukturell aussen vor — Public-Endpoint validiert
  authoritativ ohne Discovery-Surface.
- VisibilityPicker + SharedLinkControls in BuilderView, eigene Section
  mit Status-Hint wenn Form noch nicht published ist.
- SharedFormView (498 Zeilen): public-render mit allen 11 Field-Types
  (short/long_text, single/multi_choice, number, date, email, yes_no,
  rating, section, consent), Live-Branching via resolveVisibleFields
  bei jedem Keystroke, Required-Field-Validierung blockt Submit-Button.
  Submit zeigt successMessage + DEV-Hinweis (Public-Submit-Endpoint
  landet in M3.b). Mana-Branding-Footer.
- Share-Dispatcher /share/[token] kennt `forms` collection.
- 10 neue i18n-Keys × 5 Locales (forms.builder.visibility.*).

Public-Submit-Pipeline (mana-api POST → mana-sync → owner client) ist
M3.b. Bis dahin zeigt SharedFormView.handleSubmit nur die success-
Message ohne Server-Roundtrip.

svelte-check: 0 errors in forms/. Pre-existing context-removal-Drift
in cross-app-queries + widget-registry (4 errors) ist Parallel-Session-
WIP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:39:49 +02:00
Till JS
afeb32f922 feat(forms): M4a conditional branching — pure resolver + UI editor
Wenn-Dann-Logik für Form-Felder (docs/plans/forms-module.md M4 — Teil 1):

- lib/branching.ts: pure resolveVisibleFields(fields, branching, answers)
  — gibt sichtbare Subset-Liste zurück, Reihenfolge wie Original.
  Operatoren equals/not_equals/contains/is_empty mit Array-aware
  Matching (multi_choice + scalar in beide Richtungen). Aktionen
  show/hide/skip_to. show überschreibt hide bei doppelten Treffern
  (last-write-wins Layering, in Deklarations-Reihenfolge). skip_to
  versteckt alle Felder strikt zwischen Anchor und Target.
  Section/consent-Felder bleiben unbeeinflusst (kein answer-state).
- lib/branching.spec.ts: 11/11 Vitest-Cases — keine Regeln, hide+show
  Kombinationen, skip_to, contains-on-multi-choice, not_equals,
  is_empty (null/undefined/''/[]/false), Layering, fehlerhafte Refs,
  Order-Erhalt.
- components/BranchingEditor.svelte: top-level Builder-Sektion zum
  Anlegen/Editieren/Löschen von Regeln. Pro Regel: IF-Feld + Operator
  + Wert-Input (außer is_empty), THEN-Action + Target-Chips
  (multi-select für show/hide) bzw. einzelnes Feld (skip_to).
  Empty-State warnt wenn weniger als 2 Antwortfelder existieren.
- formsStore.updateBranching(id, rules) — encrypted-aware update.
- Wired in BuilderView als Section zwischen Fields und Settings.
- 18 neue i18n-Keys × 5 Locales (forms.branching.* + .op.* + .action.*).

Total Forms-Tests: 16/16 grün (5 csv + 11 branching). svelte-check: 0
errors in forms/. Pre-existing drift in context-removal-Spuren auf
main ist Parallel-Session-WIP, nicht durch mich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:31:36 +02:00
Till JS
0ef71de008 feat(forms): M3a responses view + CSV export + detail modal
In-App-Antwort-Inspektion (docs/plans/forms-module.md M3, ohne
Server-Public-Submit — der landet zusammen mit M4 Visibility):

- ResponsesView mit Status-Tabs (Alle | Neu | Gesichtet | Archiviert |
  Spam) + Live-Counts pro Tab. Tabelle: Eingegangen / Status-Chip /
  Submitter (Name → Email → Anonym-Fallback) / Antwort-Snippet (max
  60 Zeichen vom ersten non-section/consent-Feld). Klick auf Zeile
  öffnet Detail-Modal.
- ResponseDetailModal als Overlay mit ESC-Close + Backdrop-Click,
  Status-Pills oben, Submitter-Block, Antworten pro Feld in Form-
  Reihenfolge, Section-Trenner, Required-Marker, Delete-Button.
- lib/csv.ts: pure buildResponsesCsv(form, responses) — Header
  submittedAt/status/submitter/email + Form-Felder (section + consent
  ausgenommen), Werte mit RFC-4180-Escape, UTF-8 BOM beim Download.
  5/5 Vitest-Cases grün.
- BuilderView "Antworten ({n})"-Link mit responseCount-Counter.
- Route /forms/[id]/responses.
- 22 neue i18n-Keys × 5 Locales (forms.responses.* + .detail.*).

Public-Submit-Pfad (mana-api Endpoint + PublicFormView) bleibt offen
bis nach M4 Visibility.

Re-applied from notes-space-context branch (cherry 6e7972181,
forms-only subset).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:21:51 +02:00
Till JS
f104a2bc35 feat(forms): M2 builder + CRUD — drag-reorder + 11 field-types
Lieferung M2 (docs/plans/forms-module.md M2):

- BuilderView mit Title/Description-Inputs (autosave-on-blur),
  Status-Pills (draft/published/closed), Delete-Button mit confirm,
  Drag-reorder über svelte-dnd-action + flip-Animation.
- FieldEditor pro Feld: Label-Input, Pflichtfeld-Toggle, Typ-Switcher
  über alle 11 Field-Types, kollabierbare "Erweitert"-Section mit
  helpText + type-spezifischer Konfig (Optionen für choice-Felder mit
  Add/Remove, ratingScale-Toggle 5/10, min/max für number, maxLength
  für text). Type-switch räumt stale Konfig auf.
- FieldPalette mit 11 Buttons (Glyph + Label) — Klick erzeugt frisches
  Feld via makeDefaultField + dispatch an Builder.
- SettingsPanel: submitButtonLabel, successMessage, requireEmail,
  allowMultipleSubmissions, anonymous.
- field-defaults.ts: makeDefaultField(type) generiert je Typ sinnvolle
  Defaults — choice mit 2 Optionen, rating mit 5er-Skala, consent
  required + Standard-Text.
- Route /forms/[id] mit RoutePage-Wrapper.
- 38 neue i18n-Keys × 5 Locales (forms.builder.*).

Optimistic-UI: items-State wird lokal gepatcht vor store.update um
Type-Switches sofort zu rendern; field-array re-syncet bei
upstream-id-Änderung (multi-tab).

Re-applied from notes-space-context branch (cherry 7767e6761,
forms-only subset, ohne Parallel-Session context-removal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:21:27 +02:00
Till JS
75d9207ff2 feat(forms): M1 skeleton — module + Dexie v57 + welcome-seed
Erste Lieferung des Forms-Moduls (docs/plans/forms-module.md M1):

- Modul-Struktur unter src/lib/modules/forms/: types (LocalForm,
  LocalFormResponse, 11 Field-Types, BranchingRule, FormSettings),
  module.config, collections, queries (live + type-converters),
  stores (forms-CRUD inkl. add/update/remove/reorderFields,
  responses-submit mit denormalized responseCount-bump),
  ListView mit Quick-Create + Search + 3-fach Empty-State.
- Dexie v57: forms (id, status, _updatedAtIndex) + formResponses
  (id, formId, status, submittedAt, _updatedAtIndex, [formId+status]).
- Encryption-Registry typed entries: title/description/fields/branching/
  settings auf forms; answers/submitterEmail/submitterName/submitterMeta
  auf formResponses. Status, formId, submittedAt, responseCount,
  visibility, unlistedToken bleiben plaintext (Routing- + Sort-Felder).
- Per-Space-Welcome-Seed mit Beispiel-Formular (3 Felder), wired in
  data/seeds/index.ts.
- Route /forms via RoutePage (appId='forms').
- i18n-Namespace forms/ × 5 Locales (de/en/es/fr/it).

App-Registry-Eintrag (APP_ICONS.forms + MANA_APPS) ist bereits in
6d193a9fa gelandet (paralleler app-registry-polish-Commit).

Validatoren grün: validate:turbo, validate:pg-schema,
validate:i18n-parity (77 namespaces × 5), validate:theme-{parity,
utilities,variables}, audit:encrypted-tools (23 tools, M1 hat keine),
svelte-check 0/0/0 über 7680 Files. check:crypto: 213 Tables (+2
sind meine), 3 Violations sind pre-existing dead entries.

LOCAL TIER PATCH: requiredTier='guest' mit revert-Marker vor Release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:01:05 +02:00
Till JS
6d193a9fa7 chore(app-registry): polish 4 small wins — TOC + AppId-derive + route-drift test + 3 MANA_APPS
§1 AppId derivation (shared-branding):
- `AppId` is now `keyof typeof APP_BRANDING` (config.ts) instead of a
  hand-maintained union in types.ts. Adding/removing an entry in
  `APP_BRANDING` automatically updates the union — eliminates the
  drift class that produced the ContextLogo type-error.
- `AppBranding.id` relaxed to `string` to break the circular type
  reference (key in `APP_BRANDING` is the authoritative id).

§2 Route-drift smoke test (registry.spec.ts):
- New 4th test: parses every `routes/(app)/*+page.svelte`, extracts
  the `<RoutePage appId="…">` literal, asserts the id is registered
  in the workbench app-registry. Catches drift like the earlier
  `appId="broadcasts"` vs id `'broadcast'` bug structurally.
- ROUTE_ONLY_APP_IDS allowlist for routes that intentionally don't
  back a workbench module (gifts, llm-test, milestones, organizations,
  teams, tags).
- Caught two real drifts in the process and fixed them:
    /agents/+page.svelte → appId="ai-agents" → "agents"
    /agents/templates/+page.svelte → same

§3 MANA_APPS hochgezogen (kontext, wishes):
- kontext (Web-Context URL crawler) + wishes (Wunschliste) had module
  + workbench card but no MANA_APPS branding entry. Both got proper
  description, longDescription and a fresh APP_ICONS entry (globe-
  with-text-lines for kontext, shooting-star for wishes).
- Removed both from WORKBENCH_ONLY in spec — they're full apps now.
- Note: `myday` was already in MANA_APPS, the WORKBENCH_ONLY entry
  was redundant and had been silently double-counting.

§4 apps.ts — top-level INDEX comment:
- 80 registerApp() calls were chronological-by-when-added — basically
  unsearchable. Added an §1–§4 navigation comment near the top
  grouping apps by role (entity / module surface / AI Workbench /
  System) so devs can jump to a section. Physical reordering of
  the 80 blocks deferred to avoid disturbing the active multi-
  terminal session — the TOC delivers ~80% of the navigation win.

Bonus: register `forms` module that the parallel session added but
hadn't wired into the workbench yet — the new route-drift test caught
this immediately on first run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:59:26 +02:00
Till JS
230dfd5dad chore: extract arcade into standalone repo
Arcade lives as its own pnpm workspace at ~/Documents/Code/arcade
now, with no @mana/* coupling. This drops every reference and the
games/ directory from the monorepo.

Removes:
- games/ directory (89 files: web + server + 22 HTML games + screenshots)
- @arcade/web, @arcade/server pnpm workspace entries (games/* globs)
- arcade scripts in root package.json (4 scripts)
- arcade.mana.how from mana-auth trusted origins + CORS_ORIGINS
- arcade entries in mana-apps registry, app-icons, URL overrides
- arcade.mana.how from cloudflared tunnel + prometheus blackbox probes
- arcade-web service block in docker-compose.macmini.yml
- generate-env.mjs entries for arcade server + web
- BRANDING_ONLY 'arcade' entry in registry consistency spec
- dead arcade translation keys in GuestWelcomeModal (DE+EN)
- arcade mention in CLAUDE.md, authentication guideline, MODULE_REGISTRY

Verified:
- services/mana-auth/src/auth/sso-config.spec.ts: 8/8 pass
- pnpm install regenerates lockfile cleanly (-536 lines)
- no remaining 'arcade' refs outside historical snapshot docs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:40:01 +02:00
Till JS
33b3f656fd test(articles): parseUrls unit tests + extract pure module (Phase 7)
Move `parseUrls` out of stores/imports.svelte.ts (which transitively
imports Dexie via collections.ts) into a standalone parse-urls.ts so
the test file can exercise it without booting Dexie. The store re-
exports parseUrls so existing call sites (BulkImportForm, tools.ts)
keep working unchanged.

11 unit tests covering:
  - empty + whitespace-only inputs
  - newline / whitespace / comma / tab separator handling
  - http + https accepted, ftp / mailto / javascript / file rejected
  - bare domains rejected (URL accepts them as opaque, our parser
    requires explicit scheme)
  - duplicate detection preserves first-occurrence order
  - canonicalisation (trailing slash on root, query+fragment kept)
  - mixed valid / invalid / duplicate token ordering
  - title-prefixed-paste behaviour (strict — surfaces non-URL words
    as invalid for the user to see)
  - 50-URL stress check

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:39:17 +02:00
Till JS
0fc16d1bfd feat(articles): bulk-import AI tool wiring (Phase 6)
Adds import_articles_from_urls tool to the articles module so the AI
Workbench can kick off a bulk-import job in one call. Auto-policy: the
job itself is the unit of approval, no per-article propose card.

- shared-ai schemas: declare the tool name + propose/auto policy
- articles/tools.ts: implement parseUrls + articleImportsStore.createJob
- consume-pickup.ts: handle the new event type
- events/catalog.ts: register article-import lifecycle events
- imports.svelte.ts: minor polish

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:33:31 +02:00
Till JS
5f0a1b5053 feat(articles): bulk-import UI (Phase 5)
apps/mana/apps/web/src/lib/modules/articles/components/:
  - BulkImportForm.svelte: <textarea> + live-validating $derived parser,
    counter chips for valid/duplicate/invalid, expandable invalid-list,
    submit creates a job + navigates to /articles/import/[jobId].
  - JobsList.svelte: index of past + active jobs (newest first), status
    pill + progress + per-counter chips. Click row → detail.
  - JobDetailView.svelte: live header (status, progress bar, counters),
    action bar (pause/resume/cancel/retry-failed/delete), per-item rows
    with state pill + URL + open-link or error tooltip.

apps/mana/apps/web/src/routes/(app)/articles/import/:
  - +page.svelte: hosts BulkImportForm + JobsList.
  - [jobId]/+page.svelte: hosts JobDetailView.

AddUrlForm.svelte: small "Mehrere URLs auf einmal? → Bulk-Import" link
under the single-URL input so the existing flow surfaces the new path.

The whole UI is a pure liveQuery view — JobDetailView re-renders as
the server-worker writes counter updates and item-state transitions
through sync_changes. Worker tick + pickup-consumer (already shipped
in 5535f2da4 + a9bcd4183) close the loop end-to-end.

Phase 6 (Domain-Events + AI-Tool) and Phase 7 (Tests) follow.

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:30:36 +02:00
Till JS
29cbaf30f5 feat(articles): bulk-import store + queries (Phase 4)
apps/mana/apps/web/src/lib/modules/articles/:
  - stores/imports.svelte.ts: new file. articleImportsStore with
    createJob (bulkAdd N items + 1 job), pauseJob, resumeJob,
    cancelJob, retryFailed, deleteJob. parseUrls exported as a pure
    function — splits on whitespace+comma, validates http(s) scheme,
    deduplicates while preserving input order; used by both the store
    and the UI's $derived live-validation in Phase 5.
  - queries.ts: toImportJob/toImportItem converters + useImportJobs
    (index list), useImportJob (detail header), useImportItems (per-
    job item list). All scope-aware via scopedForModule / scopedGet.

Job creation: createJob(urls) → jobId. Items written first so a worker
tick that races the job-write doesn't see a job with totalUrls=N but
fewer items reachable. Server-worker picks up state='pending' items
on its 2s tick.

retryFailed re-arms the job to status='running' if it was 'done',
because all-terminal-items had triggered the auto-completion in the
worker's counter-rollup pass.

deleteJob is soft (deletedAt stamp) on both job + items; already-
landed Article rows are NOT touched.

Phase 5 (UI) follows.

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:23:45 +02:00
Till JS
fa299e3bf9 feat(app-registry): wire up 4 modules + 7 routes + tier-patch validator
Resolves the cross-cutting drift that the app-registry sanity-test was
silently catching but BRANDING_ONLY exceptions papered over.

App-registry wiring:
- Register augur, broadcasts, invoices, timeline as workbench cards.
- Resolve agents↔ai-agents naming drift: workbench id is now `agents`
  (matches MANA_APPS + the /agents route URL); folder stays `ai-agents`
  for grouping with other ai-* modules.

Broadcast→broadcasts unification:
- module.config appId, MANA_APPS id, APP_ICONS key, all route appIds,
  and the redundant APP_URL_OVERRIDES entry — all aligned with the
  earlier folder rename so nothing diverges anymore.

Top-level routes for workbench-only modules:
- /goals, /myday, /kontext, /rituals, /automations, /activity — thin
  RoutePage wrappers around the existing module ListViews.
- /timeline becomes a real module (ListView extracted from the route),
  route shrinks to a 12-line wrapper.

Food unarchive:
- packages/shared-branding/src/mana-apps.ts: remove `archived: true`
  from food entry. The module is fully wired (registered, synced,
  routed, with AI tools); the flag was outdated.

i18n cleanup:
- Rename ai-agents → agents key in all 5 apps locales.
- Drop dead "observatory" key from all 5 nav locales (route folder was
  removed in 7bca16dfa).

New CI guard — scripts/validate-tier-patches.mjs:
- Scans for `LOCAL TIER PATCH — revert before release` markers.
- Default: informational list (does not fail).
- Strict mode (MANA_TIER_PATCH_STRICT=1) for release/RC pipeline.
- Wired into validate:all.

Spec update:
- registry.spec.ts WORKBENCH_ONLY/BRANDING_ONLY: documented Settings
  family + AI Studio surfaces + intentionally-internal modules so the
  drift guard fires only on real drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:21:41 +02:00
Till JS
a9bcd4183a feat(articles): client-side pickup consumer (Phase 3)
Watches `articleExtractPickup` via liveQuery. For each row the server-
worker drops:

  1. Look up the matching `articleImportItems` row. Stale → just clean
     the inbox.
  2. Dedupe race: if the URL has been single-saved meanwhile, point
     the import item at the existing article (state='duplicate'),
     don't create a second row.
  3. Happy path: call existing articlesStore.saveFromExtracted (which
     runs encryptRecord + articleTable.add and emits ArticleSaved)
     → flip item to 'saved' (or 'consent-wall' on warning).
  4. Delete the pickup row so the inbox stays empty in steady state.

Multi-tab coordination via `navigator.locks.request('mana:articles:pickup')`
with `ifAvailable: true` — only the lock-holder consumes; other tabs
just observe the liveQuery and exit. Falls back to per-row in-memory
dedupe when the Locks API isn't available; the field-LWW server merge
forgives the rare double-process.

Wired from data-layer-listeners.ts so it boots once with the rest of
the data layer and disposes on layout unmount.

End-to-end pipeline now live:
  Client write items(state='pending')
    → sync_changes
    → server-worker tick (Phase 2)
    → Pickup row + state='extracted'
    → sync pull → liveQuery
    → saveFromExtracted (encrypt) → flip 'saved' / 'duplicate' / 'consent-wall'
    → delete pickup row

What's still needed for first user-visible test: Phase 4 (store
methods to create a job) + Phase 5 (UI). Without those there's no
way yet to inject items.

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:16:10 +02:00
Till JS
2bbcf14aba chore(geocoding): remove Pelias + close 3 bypass paths to public Nominatim
Pelias was retired from the Mac mini on 2026-04-28; photon-self
(self-hosted Photon on mana-gpu) has been the live primary since then.
This removes the now-dead Pelias adapter, config, tests, and the
services/mana-geocoding/pelias/ stack — the entire compose file, the
geojsonify_place_details.js patch, the setup.sh import script.

Provider chain is now `photon-self → photon → nominatim`. The chain
keeps its `privacy: 'local' | 'public'` split, sensitive-query
blocking, coord quantization, and aggressive caching unchanged.

Three direct calls to nominatim.openstreetmap.org that bypassed
mana-geocoding now route through the wrapper:

- citycorners/add-city + citycorners/cities/[slug]/add use the shared
  searchAddress() client (browser → same-origin proxy → mana-geocoding
  → photon-self).
- memoro mobile drops its OSM reverse-geocoding fallback entirely;
  Expo's on-device reverse-geocoding stays as the sole path. Routing
  through the wrapper would require a memoro-server proxy endpoint —
  a follow-up if Expo's quality proves insufficient.

Other behavioral changes:

- CACHE_PUBLIC_TTL_MS dropped from 7d → 1h. The long TTL was a
  privacy-amplification trick from the Pelias era; with photon-self
  serving the bulk of traffic, a transient cross-LAN blip was pinning
  cached fallback answers for days. 1h gives quick recovery.
- /health/pelias renamed to /health/photon-self; prometheus blackbox
  config + status-page generator updated.
- mana-geocoding container no longer needs `extra_hosts:
  host.docker.internal:host-gateway` (was only there for the
  Pelias-on-host-network era).

113 tests passing. CLAUDE.md rewritten to reflect the post-Pelias
architecture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:12:26 +02:00
Till JS
7bca16dfa7 feat(articles): bulk-import schema + plan (Phase 1)
Three new sync-tracked Dexie tables under the articles appId:

  articleImportJobs     — job header (counters, status, lease metadata).
  articleImportItems    — one row per URL in a job, state-machine driven.
  articleExtractPickup  — short-lived server→client handoff inbox.

URL stays plaintext on items by necessity — the server-worker reads it
without master-key access, same rationale as articles.originalUrl. The
extracted article eventually lands encrypted in the existing `articles`
table; bulk-import rows hold only pointers.

Plan: docs/plans/articles-bulk-import.md (full architecture, 7 phases,
test matrix, edge-cases). Phase 2 already shipped in 5535f2da4 (worker);
this commit lays the schema underneath it.

Originally committed as b2f4e8314, lost during a parallel reset, here
restored via cherry-pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:11:51 +02:00
Till JS
248549b15a fix(feedback): keine doppelte Anzeige von Title + Body
Bei kurzen Posts (oder wenn mana-llm fehlschlug) hat der Auto-Title-
Fallback `feedbackText.slice(0, 80)` den Body 1:1 als Title gespeichert
— Card zeigte dann zwei Mal denselben Text.

Zwei Schichten Schutz:

1. **Server (mana-analytics)**: catch-Branch wirft den Prefix-Fallback
   raus (title bleibt null). Zusätzlich neue isRedundantTitle()-Heuristik
   verwirft auch Auto-Titles, die nur ein truncierter Prefix des Bodies
   sind (Whitespace-collapse + Ellipsis-strip).

2. **Frontend (ItemCard)**: defensive showTitle-Computed — ältere DB-
   Items mit redundantem Title rendern automatisch nur den Body, ohne
   dass eine Datenbank-Cleanup nötig ist.

Title-Slot bleibt für echte Auto-Summaries und manuelle Titel sichtbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:37:51 +02:00
Till JS
f851f15a47 feat(lasts): tidy ListView header — single-row quick-add + scrollable icon-tabs
Two layout fixes for the Lasts ListView:

1. Tab bar: status filters (Alle/Vermutet/Bestätigt/Aufgehoben) get inline
   Phosphor icons + parenthesized counters. Inbox/Meilensteine/Einstellungen
   now render as full icon+label tabs in a `border-left`-separated cluster
   instead of icon-only links. The whole bar is `overflow-x: auto` with
   hidden scrollbars (matches calendar/DateStrip pattern), so narrow
   workbench cards scroll horizontally instead of wrapping.

2. Quick-add: collapses two rows (input + Vermutet/Bestätigt pill toggle)
   into one. Mode is a `<select>` styled like the category select, sitting
   to the right of the title input. Removes the visual duplication where
   the toggle pills mimicked the status tabs above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:22:40 +02:00
Till JS
941df57f77 feat(feedback): rename community-identity columns + settings-section
Letzter "community"-Rest aus dem Feedback-Hub räumt sich auf — DB-Spalten,
Settings-Search-Index, Section-Name und i18n-Keys einheitlich auf
"feedback":

- DB: auth.users.community_show_real_name → feedback_show_real_name,
  community_karma → feedback_karma. Migration unter
  services/mana-auth/sql/009_rename_community_to_feedback.sql (manuell
  via psql, in Drizzle-Schema beider Services nachgezogen).
- mana-auth/me.ts: PATCH /api/v1/me/profile akzeptiert jetzt
  feedbackShowRealName und gibt es im Response zurück.
- mana-analytics: feedback.ts liest authUsers.feedbackShowRealName /
  feedbackKarma, redact() + Karma-Increment + Tests entsprechend.
- Frontend: CommunitySection.svelte → FeedbackIdentitySection.svelte
  (Datei umbenannt, Property-Namen + Toast-Texte aktualisiert,
  HeartHalf-Icon, "Feedback-Identität" als Title).
- searchIndex.ts: CategoryId 'community' → 'feedback', anchor
  'community-identity' → 'feedback-identity'.
- i18n (5 locales): settings.categories.community → .feedback,
  settings.search.community_* → feedback_*. Labels DE/EN/FR/IT/ES
  jeweils auf "Feedback" + "im Feedback-Feed" angepasst.

38/38 Integration-Tests grün, validate:i18n-parity sauber, svelte-check 0.

BREAKING (intern, nicht live): Frontend, das gegen die alten Spalten- /
Property-Namen aus dem PATCH-Response geht, fällt jetzt um. Kein
Production-Risiko da Hub noch nicht öffentlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:09:58 +02:00
Till JS
6f83fba66a docs(reports): geocoding self-hosting decision — recommend Photon on mana-gpu
Compares Pelias / Nominatim / Photon for self-hosting on the GPU
server, with current (2026-04-28) numbers from upstream docs +
GraphHopper's Photon-data downloads:

  Photon Europe pre-built dump: 30.6 GB, weekly refresh
  Photon Germany pre-built dump: 5.8 GB, weekly refresh
  Nominatim Germany import:     ~100 GB disk, 8–12 h, 12 GB RAM
  Pelias DACH (current):         3 GB RAM, 4 services, JS patch hack

Recommendation: Photon Europe-wide on mana-gpu. Single Java process,
embedded OpenSearch, no PBF import (download a tarball, restart),
weekly auto-updates from GraphHopper, integrates with the wrapper's
existing PhotonProvider via just an env-var change.

Once self-hosted, Photon registers as `privacy: 'local'` — the
sensitive-query block (Hausarzt, Klinikum, …) gets a real local
backend and no longer has to return empty results when Pelias is
down. Public Photon stays in the chain as a `privacy: 'public'`
last-resort fallback.

Migration plan included (~3–4 h total, ~1 h waiting), with
phase-by-phase risk assessment.

Pelias does not return — the 3 GB RAM + multi-container + patched
JS combination has no operational case once we have a self-hosted
Photon that already matches our wrapper's wire format.
2026-04-28 17:04:30 +02:00
Till JS
b1fa55dbca feat(places): surface geocoding privacy notices in autocomplete UI
The mana-geocoding wrapper now returns `notice: 'fallback_used' |
'sensitive_local_unavailable'` alongside results so the UI can show
the user *why* a query had unusual behavior. This commit wires that
all the way through the Places module's address-autocomplete inputs.

Geocoding client (lib/geocoding/index.ts):
- Add `GeocodingNotice` and `SearchOutcome` types
- Add `searchAddressDetailed` and `reverseGeocodeDetailed` — same
  semantics as the existing functions but return the wrapper's
  provider/notice metadata. Existing `searchAddress`/`reverseGeocode`
  stay backward-compatible (they call the detailed variants under
  the hood and discard the metadata).
- Extend GeocodingResult with optional `provider` field.

Places ListView (the only current consumer that exposes typed
addresses to users):
- Both autocomplete inputs (tracking-edit + main address-search)
  now use searchAddressDetailed and surface notices inline.
- 'sensitive_local_unavailable' renders an amber explainer block in
  the dropdown — title + body — so the user knows why their medical
  query returned 0 hits without leaking the search to a public API.
- 'fallback_used' renders a small "≈ ungefähr" footer badge so users
  understand the result came from public OSM (less precise but
  still valid).
- The dropdown opens when EITHER results exist OR a notice is
  present — sensitive blocked queries with empty results still
  surface their explainer.

i18n: new `places.geocoding_notice.*` sub-namespace in all 5 locales
(de/en/es/fr/it) — 4 strings each. All validators green.

Other consumers (places DetailView, events, photos, contacts) keep
the existing searchAddress/reverseGeocode calls — they don't need
the privacy notices today and would just add noise. They can adopt
the detailed variant if/when the use case warrants it.
2026-04-28 16:24:15 +02:00
Till JS
112e2cc1b4 feat(feedback): rename community → feedback (module + routes + domain)
Modul, Routen und Public-Domain heißen jetzt einheitlich "feedback":

- App-Registry: id 'community' → 'feedback', name 'Community' → 'Feedback',
  Icon Megaphone → HeartHalf (passt zum bereits-globalen heart-half-Icon
  am Module-Header und im PillNav-Usermenü)
- Modul-Config: communityModuleConfig → feedbackModuleConfig
- Routen-Refs: alle href/goto-Aufrufe in Modul-Views, MyWishesView,
  Onboarding-Wish, Profile-MyWishes auf /feedback umgestellt
- /feedback/+layout: Brand "Mana Community" → "Mana Feedback", Megaphone
  → HeartHalf, "In Mana öffnen"-CTA zeigt jetzt auf /?app=feedback
- Public-Mirror Domain: community.mana.how → feedback.mana.how
  (cloudflared-config.yml + docker-compose.macmini.yml CORS_ORIGINS +
  PUBLIC_MANA_ANALYTICS_URL_CLIENT). DNS muss separat angelegt werden.
- Settings-Section: Hilfe-Text nennt jetzt feedback.mana.how

Internal: community_show_real_name + community_karma DB-Spalten bleiben
(Migration nicht im Scope dieses Renames). Settings-Search-Index-Kategorie
'community' bleibt ebenfalls — sie spiegelt das DB-Schema, nicht den
User-Begriff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:18:45 +02:00
Till JS
bcc21ca785 feat(geocoding): privacy hardening — sensitive-query block + coord
quantization + extended cache TTL for public answers

Three independent defenses limit what public geocoding APIs (Photon,
Nominatim) can learn from our outbound traffic:

1. **Sensitive-query block** (`lib/sensitive-query.ts`)
   Queries matching the medical/mental-health/crisis-service keyword
   list (Hausarzt, Psychiater, Klinikum, HIV, Frauenhaus, …) are
   never forwarded to public APIs. The chain detects sensitivity at
   the route layer and runs the search in localOnly mode — providers
   with `privacy: 'public'` are filtered out before iteration begins.
   When no local provider is available (Pelias stopped), a sensitive
   query returns ok:true with results:[] and notice:
   'sensitive_local_unavailable' so the UI can show a sensible
   message instead of "no results".

   The keyword list is documented inline. False negatives are the
   risk; false positives just produce a 0-result UX hit (better
   trade-off).

2. **Coordinate quantization** (`lib/privacy.ts`)
   Forward-search focus.lat/lon: rounded to 2 decimals (~1.1km).
     Enough for the bias to work, hides exact GPS.
   Reverse-geocoding lat/lon: rounded to 3 decimals (~110m).
     City-block resolution — sufficient for "what's near me?",
     avoids reverse-geocoding the user's exact front door.
   Pelias always gets full precision; quantization only on the way
   out to public APIs. New `privacy: 'local' | 'public'` field on
   the GeocodingProvider interface drives this.

3. **Extended cache TTL for public answers**
   New `cache.publicTtlMs` config option, default 7 days (vs. 24h
   for local-provider answers). LRU cache extended with optional
   `ttlOverrideMs` per entry. Same query from N users → 1 outbound
   request to Photon/Nominatim. Strongest privacy lever we have
   over public providers (we can't change their logging, only the
   rate at which we feed them queries).

Threat coverage:
   ✓ User IP / identity hidden (already true — wrapper is the proxy)
   ✓ Exact GPS hidden (quantization)
   ✓ Sensitive query content protected (block)
   ~ Non-sensitive query content visible (acceptable trade-off)
   ~ Aggregate profiling reduced ~10–100× (cache)
   ✗ TLS-level traffic analysis, compelled disclosure (out of scope)

Tests: 141 (was 115). New coverage:
- privacy.test.ts: quantization rules (locks the privacy claim)
- sensitive-query.test.ts: positive matches across categories +
  documented false positives we accept
- chain.test.ts: localOnly mode end-to-end including the load-
  bearing assertion that public providers' search() must NEVER be
  called when the chain is in localOnly mode (no race window)
- cache.test.ts: per-entry ttlOverride longer + shorter than default

Live smoke verified end-to-end:
- "Hausarzt Konstanz" with Pelias down → no public API call,
  notice: 'sensitive_local_unavailable'
- "Konstanz" → falls through to Photon, notice: 'fallback_used'
- Reverse with high-precision GPS → Photon receives quantized
  coords, returns city-block-level result
2026-04-28 16:04:56 +02:00
Till JS
15ab24bda8 feat(feedback): heart-half als globales Feedback-Icon + inline-Form in der Workbench
Drei Probleme adressiert:

1. **Icon-Vereinheitlichung**: alle Feedback-Affordances tragen jetzt
   das phosphor `heart-half`-Icon (statt vorher Lightbulb/Mix). Geändert
   in PillNav-Usermenü, ModuleShell-Header (FeedbackHook), Phosphor-Icon-
   Map. Eine Stelle, ein Icon — Wiedererkennung steigt.

2. **Inline statt Modal in Workbench-Cards**: AppPage.svelte rendert
   das Feedback-Formular jetzt im selben Slot wie die Hilfe-Seite —
   Klick auf das Heart-Half-Icon togglet den Inline-Panel statt einen
   Modal-Backdrop über die ganze Workbench zu legen. Hilfe und Feedback
   sind mutually-exclusive (eines geht zu, sobald das andere aufgeht).

3. **Form-Body extrahiert**: FeedbackForm.svelte enthält jetzt das
   Formular ohne jegliches Chrome. FeedbackQuickModal nutzt es im Modal-
   Mode (Standalone-Routen, PillNav), AppPage im Inline-Mode. Eine
   Quelle, beide Surfaces bleiben in sync.

ModuleShell schluckt zusätzlich `onFeedback`/`feedbackOpen`-Props: wenn
gesetzt, ruft die FeedbackHook-Komponente onClick statt das eigene Modal
zu öffnen — der Host (AppPage) übernimmt das Rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:36:52 +02:00
Till JS
94d3277e2e feat(feedback): "Idee teilen" lebt jetzt im PillNav-Usermenü
Ersetzt den schwebenden "Idee?"-Pill durch einen Eintrag im rechten
Usermenü (Profil / Credits / Idee teilen / Logout). Ein Affordance an
einer Stelle statt zwei nebeneinander.

- PillNavigation: neuer onFeedback-Prop + Lightbulb-Icon. Wenn gesetzt,
  ersetzt der Eintrag den Legacy-/feedback-Link in accountLinks und
  taucht zusätzlich oben in den userMenuBarItems (barMode) auf.
- UserMenuPanel: AccountLink kennt jetzt onClick? als Alternative zu
  href? — Action-Chips schließen das Panel direkt nach dem Klick.
- (app)/+layout: GlobalFeedbackPill-Mount entfernt, FeedbackQuickModal
  wird state-gebunden gerendert (moduleContext aus Pfad/?app= abgeleitet
  wie bisher in der alten Pill).
- GlobalFeedbackPill.svelte gelöscht — niemand referenziert sie mehr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:12:27 +02:00
Till JS
eaa1d7432b fix: silence two cosmetic boot-time devtools warnings
1. /api/auth/organization/get-active-member 400
   The Better-Auth org plugin returns 400 ("active organization not
   found") whenever the session has no activeOrganizationId yet — i.e.
   on every fresh inkognito login. The fetch was already tolerated
   (fetchActiveMember returns null on 400), but the network panel
   logged it as a noisy red row.

   Fix: gate the call on the localStorage hint. The hint is set by
   writeActiveSpaceHint() after every successful set-active, so its
   presence is a reliable proxy for "session has activeOrganizationId
   set". Without a hint we go straight to list + auto-activate
   Personal — same effective outcome, no 400.

2. Chrome "Autofocus processing was blocked" on /onboarding/name and
   /onboarding/wish
   The static `autofocus` attribute races the previous route's focus
   owner across the SvelteKit transition. Chrome refuses to honour
   autofocus when a document already has a focused element and warns.

   Fix: replace the attribute with `bind:this={el}` + a $effect that
   imperatively `el.focus()`s after `tick()` — by then the outgoing
   page has unmounted and there's no competing focus claim. The
   svelte-ignore directives are no longer needed and have been removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:10:15 +02:00
Till JS
0c30a16eb5 fix: 4 boot-time noise + correctness bugs surfaced by post-deploy smoke
All four were pre-existing; the audit smoke-test made them visible. Fixed
together because they share a "boot console-warn cleanup" theme.

1. streaks ensureSeeded race (DexieError2 ×2)
   - Two boot-time liveQuery callers passed the `count > 0` check before
     either had written, then the second's `.add()` hit a ConstraintError.
   - Fix: cache the seed promise per module, run the existence check +
     bulkAdd inside one Dexie RW transaction, and only insert MISSING
     defs (preserves existing currentStreak/longestStreak counts).

2. encryptRecord('agents', …) "wrong table name?" warning
   - The DEV-only check fired whenever a record carried none of the
     registered encrypted fields, regardless of whether anything could
     actually leak. `ensureDefaultAgent` writes a fresh agent row before
     `systemPrompt` / `memory` exist — pure noise.
   - Fix: drop the "no fields at all" branch. Keep the case-mismatch
     branch (the branch that actually catches silent plaintext leaks).

3. Passkey signInWithPasskey "Cannot read properties of undefined
   (reading 'allowCredentials')"
   - Client destructured `{ options, challengeId }` from the server's
     options response, but Better-Auth's `@better-auth/passkey` plugin
     returns the raw PublicKeyCredentialRequestOptionsJSON (no
     envelope) and tracks the challenge in a signed cookie. Both
     `options` and `challengeId` came back undefined; SimpleWebAuthn
     blew up the moment it tried to read the request shape. Verify body
     `{ challengeId, credential }` was likewise wrong — Better-Auth
     wants `{ response }`.
   - Fix: align both register and authenticate flows with Better-Auth's
     native shape on options + verify, and add `credentials: 'include'`
     on every fetch so the challenge cookie actually round-trips.
     Server's verify proxy now reads `parsed?.response?.id` for
     credentialID rate-limiting.

4. /api/v1/me/onboarding/ → 404
   - Hono's nested router (`app.route(prefix, sub)` + inner
     `app.get('/')`) matches the prefix-without-slash form only. The
     onboarding-status store sent the request with a trailing slash, so
     every login produced a 404 + a console warn.
   - Fix: client sends the path without trailing slash; mana-auth picks
     up `hono/trailing-slash` middleware as defense-in-depth so a future
     accidental trailing slash on any /me/* route 301-redirects instead
     of 404-ing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:56:24 +02:00
Till JS
4237d84c18 i18n(drink+habits+picture): translate 3 list views via $_()
- drink/ListView: route through drink.list_view.* (today/log/empty
  + create form + ctx-menu); drops unused PencilSimple equivalent
- habits/ListView: route through habits.list_view.* (voice
  capture + tally grid + create form + ctx-menu); drops unused
  PencilSimple icon import
- picture/ListView: route through picture.list_view.* (drop overlay,
  action strip, view-mode titles, search placeholder, empty states,
  lightbox actions)

Baseline 833 → 818 (-15).
2026-04-27 22:36:57 +02:00
Till JS
0f1dbe9d4c i18n(locales): add drink+habits, extend picture for list-view sub 2026-04-27 22:32:59 +02:00
Till JS
7dfa1c74be i18n(body+mood+questions): translate picker/quick-log/question-detail via $_()
- body/components/ExercisePicker: route remaining German strings
  (dialog/close/filter_all/create-form) through body.exercisePicker.*;
  drops { default: '...' } fallbacks now that all keys resolve
- mood/components/QuickLog: route through mood.quick_log.*
- questions/views/DetailView: route through questions.detail.*
  + dynamic questions.status.<id> / questions.priority.<id> /
  questions.detail.depth_<id>; drops local statusLabels/priorityLabels/
  depthLabels const blocks (re-uses existing status+priority keys
  extended with `normal`/`urgent`)

Baseline 851 → 833 (-18).
2026-04-27 19:13:18 +02:00