Commit graph

3858 commits

Author SHA1 Message Date
Till JS
daa1ef0513 feat(cards): image / audio attachments on cards via mana-media
Cards can now carry image, audio, and video attachments uploaded to
mana-media (the existing CAS service that already powers picture,
photos, wardrobe, etc.).

Pipeline:
  • lib/media/upload.ts wraps POST /api/v1/media/upload (multipart,
    app=cards). Returns { id, url, kind } with the right variant URL
    per kind (medium for images, full file for audio/video). 25 MB
    cap matches the website-upload pattern.
  • mediaToFieldSnippet(): drops Markdown ![]() for images; raw
    <audio>/<video controls> for the others — the user can later
    tweak attributes by hand.
  • Deck-detail card editor gains a "📎 Anhang" button next to every
    text field (front/back/cloze). Pick → upload → snippet appended
    to the field's content. Loading + error states surfaced inline.

Render:
  • @mana/cards-core/render.ts whitelists `audio`, `source`, `video`
    plus the `controls`/`preload`/`src`/`type` attrs in DOMPurify so
    inline media survives sanitization. Markdown's <img> already
    passed through the default policy.

Wiring:
  • hooks.server.ts injects __PUBLIC_MANA_MEDIA_URL__.
  • compose adds PUBLIC_MANA_MEDIA_URL_CLIENT=https://media.mana.how
    to cards-web.

Phase 2 ideas: drag-drop directly into the textarea, paste-from-
clipboard for screenshots, mana-media auth scoping per user, Anki
import bringing media files along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:52:53 +02:00
Till JS
1f2206f10b feat(cards-web): PDF input for AI generator + study activity heatmap
PDF input:
  • lib/ai/pdf.ts wraps pdfjs-dist (Apache-2.0). Worker is bound via
    Vite's `?worker` suffix so the heavy parsing runs off-main-thread.
  • AiCardGen gains a "📄 PDF laden" button that pipes extracted text
    into the same textarea — the user can review/trim before
    generation. Reading state shows file name + page count + chars.

Heatmap:
  • queries.useStudyHeatmap(weeks=12) fills gaps with count=0 so the
    grid renders without holes.
  • StudyHeatmap.svelte: 7 rows × N columns (Monday-anchored), 5
    intensity buckets (neutral → emerald-300), tooltip per cell with
    date + count, legend strip.
  • Mounted on the dashboard between the deck list and the Anki import
    so the user lands on a quick visual progress receipt every visit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:37:01 +02:00
Till JS
d8a35afd99 infra(gpu-box): commit GPU-Box compose to repo + Phase 2e docs
The GPU-Box stack has been carrying real production workload since
Phase 2c (monitoring) but only existed as a /srv/mana/docker-compose.gpu-box.yml
on the box itself. If the WSL filesystem dies, none of it is
reproducible. Bring the file into infrastructure/ as the source of
truth (live file on the box must be kept synchronous; manual rsync
for now since there's no CD into the GPU box).

Plus:
- infrastructure/.env.gpu-box.example as the secrets template
- infrastructure/README.md describing what runs there + how the
  Cloudflare-tunnel ingress is API-managed (not config.yml)
- .gitignore for the live infrastructure/.env.gpu-box copy
- MAC_MINI_SERVER.md status-page section now points at the GPU-Box
  setup instead of the long-stopped Mini container
- PLAN_OPTION_C.md: Phase 2e row + GPU-Box service tree update

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:28:49 +02:00
Till JS
e3cca9e271 feat(cards-web): PWA installability + AI card generation from text
PWA:
  • SvelteKitPWA in vite.config via @mana/shared-pwa preset (name +
    theme color). Layout injects pwaInfo.webManifest.linkTag so
    Chrome's manifest pickup works → install icon + A2HS.
  • Service worker registers automatically (workbox auto-update); the
    cards-already-cached path now keeps working offline as long as the
    user has visited a deck once.

AI generation:
  • lib/ai/generate.ts — direct fetch to mana-llm /v1/chat/completions
    (OpenAI-compatible, mirrors playground module). System prompt
    constrains the model to a JSON array of {front, back}. Code-fence
    stripping handles models that wrap JSON in ```json blocks despite
    the prompt.
  • AiCardGen.svelte — text in, list of generated cards out, per-card
    checkbox preview, "X übernehmen" creates them via cardStore.
    Phase-1 lands them as basic cards.
  • Mounted on the deck-detail page next to "Neue Karte".

Wiring:
  • hooks.server.ts injects __PUBLIC_MANA_LLM_URL__.
  • compose adds PUBLIC_MANA_LLM_URL_CLIENT=https://llm.mana.how to the
    cards-web service.
  • app.d.ts gets vite-plugin-pwa virtual-module references so
    svelte-check can resolve `virtual:pwa-info`.

Phase 2: PDF/image input, mana-credits gating, model selector,
streaming preview as cards arrive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:24:42 +02:00
Till JS
778e5a2ad7 chore(infra): drop status-page-gen from Mini, status.mana.how → GPU-Box tunnel
Phase 2e cleanup. status-page-gen + a dedicated nginx now run on the
GPU-Box (sparse repo clone provides the generator script + mana-apps.ts,
hourly git-pull via systemd timer). Container queries VictoriaMetrics
locally over docker-network ('http://victoriametrics:9090'), no public
vm.mana.how endpoint required — that hostname is also gone from the
GPU tunnel config (v25 → v26 effectively, removed in same PUT that
added status.mana.how).

DNS for status.mana.how now points at the mana-gpu-server tunnel.
Mini-tunnel ingress for it is removed; the previous 'mana-status-gen'
container on the Mini was stopped + rm'd.

Side benefit: closes the inode-stale-bind-mount bug that took status.
mana.how down for a few hours — single-file bind mounts on the Mini
break whenever the CD git-checkout rewrites the source file. The
GPU-Box mounts the same files but the systemd timer git-pulls in-
place, preserving the inode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:22:20 +02:00
Till JS
22cce59c3a feat(cards-web): Anki .apkg import — first acquisition lever
Anki users have decks they hate the UI for but can't migrate. This
gives them a one-drop path: drop a .apkg on the homepage, see a
preview, confirm, the cards land in our DB and start syncing.

Pipeline (lib/anki/):
  • parse.ts — JSZip → sql.js (WASM SQLite) → walk Anki's three core
    tables (col, notes, cards). Models (col.models JSON) classify each
    note: type=0 → basic / basic-reverse, type=1 → cloze. Anki cards
    table has one row per generated learnable unit (basic-reverse = 2,
    cloze = N) — we dedupe at the note level since our model
    regenerates those automatically via reviewStore.ensureReviewsForCard.
  • import.ts — every Anki deck becomes one of ours (1:1, "::" → " / ");
    fields go through sanitizeAnkiHtml (drops <img>, [sound:], maps
    <b>/<i> to Markdown). Orphans land in a fallback "Anki-Import"
    deck.

UI: AnkiImport.svelte on the decks list — drag-drop or click,
parse → preview ("X Decks, Y Karten"), confirm → import. No images,
no audio, no review history (cards are FSRS-new on import) — those
are Phase 2.

Deps: sql.js 1.14, jszip 3.10, @types/sql.js. WASM blob copied into
static/ so SvelteKit serves it at /sql-wasm.wasm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:02:29 +02:00
Till JS
0c2df08149 fix(status-page): point at vm.mana.how (GPU-Box VM) instead of localhost:9090
After Phase 2c VM moved off the Mini, but the status-page generator
still queried localhost:9090 — and Colima containers can't reach the
GPU-Box's LAN IP through the Mini's bridge. Result: status.mana.how
showed 0/0 services UP across the board.

Routed VM through a new vm.mana.how Public Hostname on the
mana-gpu-server tunnel (config v24) so the Mini-side container reaches
it the same way browsers do. /api/v1/query path is identical, no
script changes required. Network-mode no longer needs to be host now
that the URL is public.

Verified live: status.json reports 49/53 services UP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:58:19 +02:00
Till JS
009fb3589e feat(cards-web): streak indicator + per-deck due counts
Two small UI surfaces over data the backend already computes:
  • Header shows current streak (🔥 N) — useStreak() walks back through
    cardStudyBlocks until it finds a gap.
  • Decks list shows a "fällig"-pill per deck and a total in the header
    subline — useDueCountByDeck() joins cardReviews→cards once and
    groups by deckId.

Both queries live in lib/queries.ts and use Dexie liveQuery, so the
header refreshes automatically the moment a learn session ticks the
study block forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:45:04 +02:00
Till JS
585bee42be docs(mac-mini): refresh container counts + memory budget after Phase 2c+2d
53→45 running, 9.6 GiB nominal → ~6 GiB nominal, build-app.sh's
monitoring-stop trigger no longer fires. Cross-link to PLAN_OPTION_C.md
for the GPU-box-side picture (grafana/git/stats/glitchtip live there
via the mana-gpu-server tunnel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:44:22 +02:00
Till JS
0db64cb47b chore(infra): drop migrated services from Mini compose + tunnel config
Phase 2c+2d cleanup. The 14 services that moved to the GPU-Box stack
(grafana, victoriametrics, loki, tempo, promtail, alertmanager,
vmalert, pushgateway, blackbox-exporter, alert-notifier, umami,
glitchtip + worker, forgejo) are now stopped on the Mini and stable
on the GPU box, so the rollback insurance can come out:

- docker-compose.macmini.yml: drop 14 service blocks (-369 lines) +
  the now-orphan named volumes (victoriametrics_data, loki_data,
  alertmanager_data, grafana_data, tempo_data).
- cloudflared-config.yml: drop the four hostnames whose DNS already
  points at the mana-gpu-server tunnel — Mini-tunnel ingress for them
  has been dead routing since 2026-05-06, removing the rules just makes
  the file match reality. The hostnames now live in the GPU tunnel's
  dashboard config (token-managed).

Containers + volumes stay on the Mini for now; running
`docker compose -f docker-compose.macmini.yml --env-file .env.macmini up -d --remove-orphans`
on the box drops them in one go when ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:39:43 +02:00
Till JS
f422fd6779 fix(shared-error-tracking): point main at src/, strip dashes from Glitchtip DSN
Two real-world fixes from wiring mana-auth to Glitchtip:

1. The compiled dist/ folder was excluded from Docker builds via
   .dockerignore's '**/dist' rule, so any container that pnpm-installed
   the package found node_modules/@mana/shared-error-tracking but no
   loadable entry point ('Cannot find module' at startup). Match the
   pattern shared-hono uses — point main + types + exports straight at
   src/*.ts. Bun runs TS natively and the type-only consumers don't
   care.

2. Glitchtip projects expose UUID-format public keys (`556fbd2e-a720-…`)
   in their generated DSNs. @sentry/node v9 tightened its DSN regex to
   alphanumeric-only, so it silently rejects the DSN with "Invalid
   Sentry Dsn" and never sends events. Strip the dashes from the
   user/key portion before handing it to Sentry — the Glitchtip ingest
   endpoint accepts both forms over the wire, so no server change.

Plus the missing Dockerfile COPY lines for shared-error-tracking and
eslint-config (root package.json devDeps reference the latter, which
breaks pnpm-filter installs that don't include it in the build context).

Verified end-to-end: 4 issues now in Glitchtip from mana-auth
(2 manual probes + 1 captureException + 1 401 from a
real /api/v1/me/data request without auth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:34:54 +02:00
Till JS
1bac7cf38a fix(mana-auth): COPY packages/shared-error-tracking in Dockerfile
Mirror the same fix as cards-core (dd2e60954): the new
shared-error-tracking workspace dep needs an explicit COPY line
in the installer stage, otherwise pnpm-install-with-filter can't
find the package and the runtime container is missing it under
node_modules/@mana/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:03:25 +02:00
Till JS
96c06162e6 fix(cards-web): inject __PUBLIC_MANA_AUTH_URL__ on SSR — login was 404
createManaAuthStore from @mana/shared-auth-ui reads the auth backend
URL from window.__PUBLIC_MANA_AUTH_URL__ at runtime. Without the
injection it falls back to a relative URL, so signIn POSTs land at
cards.mana.how/api/v1/auth/login (SvelteKit 404, HTML body) instead
of auth.mana.how/api/v1/auth/login.

Adds a hooks.server.ts modeled after the mana-web one, but trimmed
to the two URLs the standalone app actually consumes today (auth +
sync). The values come from PUBLIC_MANA_AUTH_URL_CLIENT and
PUBLIC_MANA_SYNC_URL_CLIENT in docker-compose.macmini.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:02:29 +02:00
Till JS
8b71d290f8 feat(mana-auth): wire Glitchtip/Sentry error tracking via shared-error-tracking
First server-side error-tracking integration. Pattern mirrors the
client-side one in apps/mana/apps/web/src/hooks.client.ts:

- pull @mana/shared-error-tracking into mana-auth's deps (workspace pkg
  with @sentry/node + a no-op fallback when GLITCHTIP_DSN is unset)
- call initErrorTracking() at the top of services/mana-auth/src/index.ts
  before the rest of the module body executes — this lets Sentry hook
  uncaughtException / unhandledRejection before any Hono handlers register
- wrap app.onError so non-HTTPException throws also flow into
  captureException with path/method/query context. HTTPExceptions are
  intentional 4xx/422 and stay out of the issue list (otherwise every
  401 from a stale session would page somebody at 3am)
- compose: pass GLITCHTIP_DSN_MANA_PLATFORM through as GLITCHTIP_DSN per
  service so each container's events get tagged with serverName='mana-auth'

DSN itself isn't in the repo; lives in .env.macmini on the Mac Mini and
is referenced from the Glitchtip credentials doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:55:07 +02:00
Till JS
dd2e609545 fix(docker): COPY packages/cards-core in SvelteKit Dockerfiles
The cards-spinoff commit (0a544ac41) added @mana/cards-core as a
workspace dependency for apps/mana/apps/web but didn't update the
two Dockerfiles that COPY-and-pnpm-install the workspace into the
image. CD's --no-cache build for mana-web therefore failed at
`pnpm install` with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND, leaving the
container on a stale pre-cleanup image whose ListView28 chunk still
referenced the dropped contextSpaces Dexie table — every mana.how
route 500'd.

Adding the COPY line to both files (the shared sveltekit-base layer
and the per-app layer that does a second pnpm install) makes the
package available to the workspace resolver and lets the build go
through.

Plus the Phase 2c-d doc updates that piled up today (Glitchtip
on dedicated GPU-box stack, gitignore for *_CREDENTIALS.md files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:47:07 +02:00
Till JS
86f14bcc19 fix(cards-web): drop unused @mana/shared-crypto dep — not in sveltekit-base image
The Phase-1 crypto wrapper is a no-op stub; it never actually imports
@mana/shared-crypto. The dep was forward-looking for the Phase-2 vault
wiring, but it broke `pnpm install` inside the cards-web Dockerfile
because the sveltekit-base image only ships a curated subset of
@mana/* packages and shared-crypto isn't one of them.

The wiring will come back when the vault roundtrip is on, together
with a base-image bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:46:50 +02:00
Till JS
f94c047daa chore: silence pre-existing svelte-check a11y warnings
Pre-push hook runs svelte-check with --fail-on-warnings; nine
long-standing warnings in unrelated files (forms / website-blocks)
were blocking otherwise-clean pushes.

Each <label> here is a visual label whose control follows on the next
line — accessible to a screen reader through proximity but not through
a `for=`/`id` association. The state_referenced_locally cases capture
a prop on first render by design (re-running the hook on prop change
would be a different feature). The <nav role=tablist> is the existing
tab-strip semantic.

All seven sites get scoped svelte-ignore comments rather than functional
rewrites — the goal is to unblock CI, not redesign these components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:34:36 +02:00
Till JS
0a544ac410 feat(cards): Phase-1 Spinoff — standalone cards.mana.how + cards-core extraction
Builds out the Cards spinoff end-to-end so the standalone app at
cards.mana.how shares its data layer with the in-mana cards module
through a single pure-utility package.

Why a spinoff and not just a deeper module: per the GUIDELINES, Cards
gets its own brand + URL while reusing mana-auth, mana-sync, and the
mana-credits/billing stack. The in-mana module under mana.how/cards
stays untouched as the integrated experience.

Phase 0 — mana-modul foundation
  • New tables cardReviews + cardStudyBlocks (Dexie v61) + plaintext
    classification in the crypto registry.
  • LocalCard learns a {type, fields} shape; legacy front/back columns
    kept as a back-compat mirror so older builds keep rendering.
  • FSRS v6 scheduler + Cloze parser + Markdown render pipeline.
  • UI in apps/mana/.../routes/(app)/cards/ gets a learn session
    (learn/[deckId]), 4-type card editor, due-counter, markdown lists.

Phase 1 — standalone (apps/cards/apps/web)
  • SvelteKit 2 + Svelte 5 + Tailwind 4, port 5180.
  • Own Dexie 'cards' DB with a slim 5-table schema.
  • Own sync engine: pending-changes hooks, 1 s push / 5 s pull against
    POST /sync/cards, server-apply with suppression to avoid ping-pong.
  • Auth-Gate via @mana/shared-auth-ui (LoginPage / RegisterPage).
  • Encryption hooks at every write/read/apply path, currently no-op
    stubs — flipping to real vault-backed AES-GCM is a single-file
    change in src/lib/data/crypto.ts.

Shared package — @mana/cards-core
  • Pulls types, cloze, card-reviews, FSRS wrapper, and Markdown
    renderer out of the mana module so both frontends import from one
    source. mana-modul keeps thin re-export shims so consumers don't
    need to change imports.
  • 19 vitest tests carried over from the mana module.

Server-side wiring
  • cards.mana.how added to mana-auth PRODUCTION_TRUSTED_ORIGINS and
    its CORS_ORIGINS env (sso-config.spec.ts stays green).
  • New cards-web container in docker-compose.macmini.yml (mirrors
    manavoxel-web pattern, 128m, depends on mana-auth healthy).
  • cloudflared-config.yml repoints cards.mana.how from :5000 (the
    unified mana-web container) to :5180. mana.how/cards is unchanged.

Cleanup
  • Removed an unrelated 2026-03/04 NestJS+Supabase+Expo experiment
    that was lingering under apps/cards/ (apps/landing, supabase/,
    .github/workflows, MANA_CORE_*.md, etc.). It predated this plan
    and would have confused future readers.

Validation
  • svelte-check on mana-web: 0 errors over 7697 files
  • svelte-check on cards-web: 0 errors over 3481 files
  • vitest on cards-core: 19/19 pass
  • pnpm check:crypto: 214 tables classified
  • bun test sso-config.spec.ts: 8/8 pass
  • vite build on cards-web: green

Not done in this commit (deliberate)
  • Real encryption (vault roundtrip) — Phase 2.
  • WebSocket-driven pull (5 s polling for now).
  • Mobile/landing standalone surfaces — Phase 2/3.
  • The actual production cutover on the Mac mini (build, deploy,
    cloudflared sync) — config is staged, deploy is a user action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:20:43 +02:00
Till JS
950b822070 docs(cards): Phase-1 Spinoff-Guidelines — Core-Gameloop, Stack, Datenpfad
Verbindliche Leitlinien für den Cards-Spinoff (Karteikarten-App mit
Spaced Repetition). Status: Planungsphase, noch kein Code. Doc dient
als nicht-verhandelbarer Kontext für PRs sobald gebaut wird.

Wichtigste Festlegungen:
- Game-Dev-Prinzip: Phase 1 baut NUR den Core-Gameloop (Lernsession).
  KI-Generierung, Voice, Sharing, Stripe, Mobile, Dashboards = Phase 2+.
- Open-Source-only: jede Dep braucht OSI-konforme Lizenz.
- Zentrale Mana-Bausteine sind Pflicht, kein Eigen-Auth/Sync/Analytics.
- Daten-Contract mit dem bestehenden mana-Modul: gleiche Postgres-
  Tabellen (cardDecks/cards + neu cardReviews/cardStudyBlocks),
  appId='cards'. Schema-Änderungen rolled-out gemeinsam, nicht einseitig.
- FSRS v6 via ts-fsrs für Spaced-Repetition-Algorithmik.
- Phase 1 hat keinen eigenen Service — Lese-/Schreibpfad geht
  ausschließlich über IndexedDB → mana-sync → Postgres.

Definition of Done in §7 ist die Acceptance-Liste fürs MVP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:39:26 +02:00
Till JS
c8a292b891 tunnel: route memoro.mana.how + memoro-app/api/audio to standalone Memoro stack
memoro ist seit längerem ein eigener Repo (Code/memoro/) mit eigenem
Compose-Stack auf dem Mini (~/projects/memoro-deploy/). Der Tunnel
zeigte bisher trotzdem auf die unified mana web app (Port 5000) — d.h.
memoro.mana.how rendert nur das Mana-Dashboard, nicht die echte
Memoro-Marketing-Landing.

Vier Hostnames in einem eigenen Memoro-Block:
  memoro.mana.how       → :3120  (Astro-Landing, Marketing-Site)
  memoro-app.mana.how   → :3130  (SvelteKit-SPA, Web-App)
  memoro-api.mana.how   → :3110  (API)
  memoro-audio.mana.how → :3101  (Audio-Service)

memoro-app vs memoro auf erster Subdomain-Tiefe gelassen damit
Cloudflare Universal SSL ohne Wildcard-Konfig greift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:39:12 +02:00
Till JS
c14aef9f85 docs(infra): Mac-Mini ↔ Windows-GPU-Box workload-split — Plan Option C
Hilfsdienste (Monitoring, Forgejo, Glitchtip, Umami) wandern von der
auslast­ungs-kritischen Mac-Mini-Box auf die Windows-GPU-Box, die
ohnehin 95 % System-RAM idle hat. Production-Hot-Path bleibt auf dem
Mini, kein Geld ausgegeben, Single-Point-of-Failure am Standort
reduziert.

Stand 2026-05-06: Phase 0–2b shipped (WSL2-Docker, Grafana cross-box,
Forgejo, Umami healthy). Phase 2c (Loki+VM+Alerts) und Phase 4
(Cloudflare-Cutover für grafana.mana.how) brauchen eigene Sessions —
beides Pre-existing-Mis-config-Aufräumen, kein Architektur-Risiko.

Hardware-Inventar in WINDOWS_GPU_SERVER_SETUP.md ergänzt: Ryzen 9 5950X,
64 GB DDR4, RTX 3090, 660 GB frei C:. WSL2 auf 24 GB / 12 vCPU
gedeckelt damit AI-Scheduled-Tasks > 30 GB Reserve haben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:39:01 +02:00
Till JS
1b579ab0b0 chore(mana-events): move from port 3065 to 3115 — collision with platform mana-media
Platform-Repo (Code/mana/) reserviert 3065 für mana-media; um Doppel-
Belegung zu vermeiden wandert mana-events (Public-RSVP / Event-Sharing)
auf 3115. Neuer Port-Block 311x ist unbenutzt und gehört strukturell
neben mana-mail (3042) bzw. die anderen 30xx Service-Ports.

Berührt jeden harden-coded 3065-Default — Server-Config, Webapp-Config,
SSR-Routes (rsvp/[token], status), Playwright-Webserver-Setup, e2e-Spec.
PUBLIC_MANA_EVENTS_URL in .env.development zieht beide Variablen mit.

PORT_SCHEMA.md trägt jetzt den Wechsel mit Datum + Begründung —
zukünftiges Ich soll nicht raten warum der Port aus der 30xx-Reihe
ausschert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:38:46 +02:00
Till JS
546b94d472 feat(personas): move admin + internal endpoints from mana-auth to apps/api
Schließt die platform/product-split-Lücke: HEAD's apps/api/src/index.ts
referenziert seit dem Forms-M10d-Commit personasInternalRoutes /
personasAdminRoutes — die Implementierung lag aber noch nicht im Repo.
Build war strukturell broken bis hierhin.

Was wandert von mana-auth nach apps/api:

  apps/api/src/modules/personas/
    ├── schema.ts          — pgSchema('personas') mit personas /
    │                        persona_actions / persona_feedback;
    │                        userId ist plain text (Cross-DB-FK auf
    │                        mana-auth's auth.users geht nach Split nicht).
    ├── internal-routes.ts — service-key gated GET /due, POST /:id/actions
    │                        und POST /:id/feedback. Append-only +
    │                        idempotent über deterministische row-ids
    │                        (tickId-i-tool / tickId-module).
    └── admin-routes.ts    — admin-JWT gated CRUD; ruft mana-auth via
                             /api/v1/admin/users + /api/v1/auth/register
                             + /api/v1/internal/users/:id/persona-stamp
                             für den User-Lifecycle.

Persona-runner-Client zeigt jetzt auf apps/api:

  - config.ts: neues apiUrl-Feld (default http://localhost:3060,
    Env MANA_API_URL); authUrl bleibt für /api/v1/auth/login + spaces.
  - clients/mana-auth-internal.ts: drei Calls treffen jetzt
    /api/v1/personas/internal/* statt mana-auth's
    /api/v1/internal/personas/* — Datei-Name bleibt um Call-Site-Diff
    klein zu halten.
  - index.ts: ManaAuthInternalClient bekommt config.apiUrl statt authUrl.

Seed/Cleanup-Skripte:

  - --api= als bevorzugter Flag, --auth= als Legacy-Alias (cached
    Shell-History würde sonst hart brechen).
  - default http://localhost:3060, Env MANA_API_URL.
  - Endpoint-Pfade umgeschrieben:
      POST   /api/v1/admin/personas        → /api/v1/personas/admin
      DELETE /api/v1/admin/personas/:id    → /api/v1/personas/admin/:id

drizzle.config.ts: schema-Array + schemaFilter um 'personas' erweitert.
DB-push ist Pflicht-Schritt vor erstem Boot, sonst 42P01 auf /due.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:38:29 +02:00
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
fc1cde9d22 tunnel: route admin.mana.how + npm.mana.how to the mana-platform stack
Two new public hostnames pointing at containers that live in the
separate mana-platform repo (Code/mana, ~/projects/mana-platform on
the Mac Mini):

- admin.mana.how → :3071 (mana-admin, Verein backoffice)
- npm.mana.how   → :4873 (Verdaccio, private @mana/* npm registry)

Both deployed alongside the legacy stack via
infrastructure/docker-compose.macmini.yml in the mana-platform repo.
No change to existing routes.
2026-05-05 15:46:20 +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
59373c0d57 chore(articles): hygiene pass — shared-ai actor + lib/sync-db + metrics (#5,#7,#11)
#5 — SYSTEM_ARTICLES_IMPORT_WORKER hoisted into @mana/shared-ai
   The worker built its actor inline, bypassing the SystemSource union
   that's the blessed list for system-write principals. Now uses
   makeSystemActor(SYSTEM_ARTICLES_IMPORT_WORKER) like every other
   server-side system writer (mission-runner, projection, …).

#7 — sync-db helper hoisted out of mcp/ into lib/
   Implementation moved to apps/api/src/lib/sync-db.ts; mcp/sync-db.ts
   is a re-export shim so existing MCP imports keep working. Articles
   bulk-import + future modules import from lib/ directly — no more
   "articles depending on mcp" layering smell.

#11 — Prometheus metrics for the worker
   New counters + histogram in lib/metrics.ts under
   mana_api_articles_import_*:
     - ticks_total{result=processed|skipped|error}
     - items_total{result=extracted|error|consent_wall|cancelled}
     - extract_duration_seconds (histogram, 0.25–30s buckets)
     - jobs_completed_total{result=done}
     - pickup_gc_rows_total
   Worker tick + extractor instrumented at the right transition points.
   Steady-state pickup_gc_rows_total > 0 over time signals a stuck
   consumer somewhere — useful operator alert.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:06:15 +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
b297f68ee4 fix(articles, mana-ai): rollout-block hardening for sync_changes projections
Four cross-cutting fixes that make the bulk-import worker safe to run
under real production load. All four were called out as live-rollout
risks in the post-ship review of docs/plans/articles-bulk-import.md.

#1 — Same fieldMetaTime bug fixed in mana-ai
   The articles fix in 054b9e5be hoists the helper to its own file
   `apps/api/src/modules/articles/field-meta.ts`. The same naive
   `rowFM[k] >= localTime` LWW comparison existed in three more
   projections under services/mana-ai (missions-projection,
   snapshot-refresh, agents-projection). Once any F3 stamp lands
   beside a legacy-string stamp, the comparison evaluates
   `'[object Object]' >= 'ISO-…'` (false) and the older value wins.
   New `services/mana-ai/src/db/field-meta.ts` — same helper,
   deliberately duplicated (each service treats sync_changes as a
   read-only event log; sharing infra across services is out of
   scope here). All 61 mana-ai bun tests still pass.

#2 — Stale 'extracting' items recycle
   If the worker dies mid-fetch (OOM, pod restart), items stay in
   state='extracting' forever and the job never completes. New sweep
   at the start of `processOneJob`: items whose lastAttemptAt is
   older than 5 minutes get bounced back to 'pending' so the next
   tick re-claims them. STALE_EXTRACTING_MS tuned for the 15s
   shared-rss fetch + JSDOM-parse worst case.

#3 — Pickup-row GC
   Every 30 ticks (~once per minute) the worker hard-deletes
   articleExtractPickup rows older than 24h. Without this a stuck
   pickup-consumer (all tabs closed, Web-Lock mismatch) would let
   sync_changes accumulate without bound. Logs the row count when
   non-zero so we can spot stuck consumers in the wild.

#4 — DRY consent-wall heuristic
   Identical CONSENT_KEYWORDS + threshold lived in routes.ts AND
   import-extractor.ts. Hoisted to
   `apps/api/src/modules/articles/consent-wall.ts`; both call sites
   now share one heuristic.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:53:39 +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
5c8faae4ea chore: drop remaining context module legacy refs
Follow-up sweep after acb737e25 — the context module's UI + Dexie
tables + AI route were already removed, but a handful of registry-style
refs in the monorepo's plumbing still pointed at the dead module:

- packages/shared-utils/src/analytics.ts: drop the `context: createModuleTracker('context')`
  entry from the `track` map and delete the unused `ContextEvents`
  helper (no consumers — every analytics call site that used it lived
  in the deleted module).
- packages/shared-utils/src/analytics.ts (cont.): the deletion above
  removes the only typed reference to track.context, so the property
  cleanly disappears from the inferred type.
- package.json: drop 6 dead npm scripts (`context:dev`, `dev:context:web`,
  `dev:context:app`, `dev:context:full`, `dev:context:local`, `setup:db:context`)
  — all referenced `@context/*` workspace packages that were removed
  with the module. `pnpm context:dev` would silently succeed-with-zero-targets
  before; now it correctly errors as unknown script.
- scripts/generate-env.mjs: drop the two `apps/context/apps/{server,web}/.env`
  generator entries pointing at non-existent app directories.
- scripts/validate-monorepo.mjs: drop `'@context/'` from the internal
  workspace prefix list — fences a class of dependency that no longer
  exists.
- .env.development: fix a stale comment pointing at the renamed
  /api/v1/context/import-url endpoint (now /api/v1/kontext/import-url
  per acb737e25).
- apps/context/: delete the leftover directory (CLAUDE.md describing
  vanished paths + a package.json with a `dev:mobile` script filtering
  the @context/mobile package that was deleted with all per-product
  mobile apps on 2026-04-20).

What remains and is intentional: historical plan docs / devlogs /
audit reports / generated complexity-map.html / Dexie v57 drop
migration / pnpm-lock.yaml (regenerates on next `pnpm install`).
Unrelated `'context'` strings (MemoryCategory enum, Kontext-Agent
template id, encryption-vaults DB column, Astro landing /context
content collection) stay — different concepts that happen to share
the word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:20:45 +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