Commit graph

3420 commits

Author SHA1 Message Date
Till JS
1f392c1ea6 feat(broadcast): M1 skeleton — module registration + empty ListView
New 1:N email-campaign module (newsletters / announcements). M1 scope:
- types (LocalCampaign / LocalBroadcastTemplate / LocalBroadcastSettings),
  constants (STATUS_LABELS, BROADCAST_SETTINGS_ID, rate-limit hints)
- collections.ts: Dexie table refs, no guest seed (a demo campaign that
  might accidentally hit real SMTP felt wrong)
- module.config registered in module-registry
- Dexie v32 wired in (already in tree from a parallel Spaces commit
  picking it up via lint-staged — matches what the module expects)
- encryption registry entries for all three tables (type-safe via
  entry<T>), content + audience always encrypted because the recipient
  graph is a leakable business secret
- app entry (requiredTier: alpha) + megaphone gradient icon
  (indigo→cyan, sits between mail and invoices in the comm family)
- route /broadcasts mounts ListView with empty-state placeholder

Status machine defined: draft → scheduled → sending → sent, with
cancelled as the off-ramp from draft/scheduled. No CRUD yet — that's M2.

Plan: docs/plans/broadcast-module.md.
Next: M2 AudienceBuilder + Tiptap editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:16:35 +02:00
Till JS
79a6da3e2e feat(spaces): move access tier from user to space
Migration from user-level tier to Space-level tier, following the
Spaces foundation plan. User-visible effect: the tier that gates
module access now belongs to the active Space, not the user account.
Personal Spaces inherit the user's old tier on signup so nothing
downgrades.

shared-types:
- New SpaceTier type ('guest' | 'public' | 'beta' | 'alpha' | 'founder').
- New spaceTierMeets(actual, required) helper.
- SpaceMetadata gains an optional `tier` field.

mana-auth:
- createPersonalSpaceFor reads user.accessTier and stamps it into the
  personal Space's metadata.tier. A founder-tier user setting up their
  first Space keeps founder access in that Space.
- databaseHooks.user.create.after now forwards accessTier into the
  personal-space creator.

apps/web (scope layer):
- ActiveSpace gains a required `tier: SpaceTier`; rawToActiveSpace
  reads it from organization.metadata, defaulting to 'public' if
  missing or invalid.
- New getEffectiveTier(userFallback) helper resolves the tier to use
  for gating: prefers the active Space's tier, falls back to the
  caller-supplied user tier during the boot window.

apps/web ((app) layout):
- `effectiveTier` $derived replaces every authStore.user?.tier reference
  in the layout's access-gating logic (appItems, routeBlocked,
  routeTierLabels). AuthGate deeper in the UI keeps using user.tier as
  its own fallback — the tier move is additive, not destructive.

What this does NOT do yet:
- The user.accessTier column still exists and is still the initial
  source for personal-space tier. Removing it is a later cleanup once
  every code path reads through the Space primitive.
- No admin API for setting tier on a Space (PUT /api/v1/admin/spaces/
  :id/tier). Follow-up when admin tooling needs it — today admins still
  set user.accessTier, which flows to the personal space on next
  signup.

Resolves the MANA_APPS-tier-patch workaround memory: future sessions
can adjust tier per Space instead of per User.

0 errors across 7151 files. 10/10 scope tests pass.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:10:06 +02:00
Till JS
88e3adb9d3 feat(spaces): multi-member RLS policy in mana-sync (forward-compat)
Adds the second RLS policy needed for shared spaces. Users can read
rows in any space they're a member of, in addition to their own rows.

Changes:
- New policy sync_changes_space_member_read (SELECT only) uses
  app.current_user_space_ids session config: rows with space_id in
  that comma-separated list pass RLS.
- WITH CHECK is not extended — writes still require user_id match, so
  only the author can write. Members read, owner/author writes.
- withUser() is now a thin wrapper around withUserAndMemberships(),
  which accepts the caller's Space membership list and sets the new
  session config alongside app.current_user_id.
- The comma-join is empty-filtered so stray blank entries can't match
  rows with literal empty space_id (defense in depth).

Forward-compatible: today every space has exactly one member (the
author), so the membership list is always empty and the new policy
is a no-op — user_id isolation remains the only active guard.

When shared spaces start being used (clubs/teams/brand spaces with
invites), the HTTP handlers will fetch the caller's membership from
mana-auth and pass it to withUserAndMemberships. No migration needed
at that point — the policy is already live.

Subscription fan-out (WS/SSE broadcast to all space members) is still
per-user; that's a follow-up tied to the membership lookup infra.

Go build + existing tests pass.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:55:17 +02:00
Till JS
4ff95b2315 feat(spaces): rename legacy spaceId → contextSpaceId (v31 migration)
Resolves the name collision flagged in the Spaces RFC: four tables
owned the term "spaceId" before the multi-tenancy Spaces foundation
landed in v28 (conversations, documents, spaceMembers, memoSpaces
— chat's context-folder reference, context's parent context-space,
and memoro's membership/join tables). After v28, the scope wrapper
started filtering on a field that meant something different in these
tables, which would have hidden their records from the UI.

Dexie v31 migration:
- Renames the index from spaceId → contextSpaceId on all four tables.
- upgrade() copies each existing `spaceId` value to `contextSpaceId`
  (when it's a real context-space reference and not already the v28
  `_personal:<userId>` sentinel), then resets `spaceId` to the
  personal-space sentinel so the scope wrapper picks the row up on the
  active-space boot pass.

Type changes:
- LocalConversation, Conversation:           spaceId → contextSpaceId
- LocalDocument:                             spaceId → contextSpaceId
- LocalSpaceMember, LocalMemoSpace (memoro): spaceId → contextSpaceId

Code updates:
- chat/queries.ts: toConversation + filterBySpace renamed to
  filterByContextSpace (exports updated in chat/index.ts).
- chat/stores/conversations.svelte.ts: create() param + write site.
- context/queries.ts: toDocument + useSpaceDocuments signature.
- context/collections.ts: seed data.
- context/ListView.svelte + route pages: form data.
- dashboard/widgets/ContextDocsWidget.svelte: read site.

Table names stay: `spaceMembers` and `memoSpaces` still carry their old
names because they belong to the memoro module's context-space concept
and table renames also require sync-routing updates. A dedicated
cleanup can rebrand those once memoro's data model is revisited.

0 errors across 7148 files.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:52:44 +02:00
Till JS
a12928b7d8 fix(shared-types): add .ts extensions to re-exports for Node ESM resolvers
Tailwind v4's module loader follows imports out of @source-scanned
packages (e.g. shared-branding/spaces.ts imports from @mana/shared-
types) via Node's ESM resolver, which mandates explicit extensions on
relative paths. Without them Vite's Tailwind integration blew up
with `Cannot find module '.../shared-types/src/theme'` at dev-server
boot.

Downstream tsconfigs all run `moduleResolution: "bundler"`, which
accepts `.ts` suffixes on relative imports without requiring
`allowImportingTsExtensions`. No downstream code changes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:50:22 +02:00
Till JS
00b1c9b378 feat(spaces): migrate 43 modules to scopedForModule (Batches A–D)
Mass rollout of the scope wrapper to every module that had a simple
db.table('X').toArray() or .orderBy('k').toArray() pattern. The
calendar/todo/notes/contacts pilots stay as the original templates;
this commit adds the rest in one pass so the scope layer is the
universal read path.

Modules migrated (43):
  Batch A (health/tracking):  body, mood, sleep, period, habits,
                               dreams, journal, meditate, drink, food
  Batch B (content/media):     recipes, plants, places, firsts, who,
                               library, quotes, music, photos, picture,
                               presi, cards, wishes
  Batch C (productivity):      events, finance, invoices, times, storage,
                               uload, inventory, skilltree, citycorners,
                               guides, questions, quiz
  Batch D (AI/tools):          chat, context, kontext, memoro, mail,
                               companion, moodlit, wetter, playground,
                               calc, stretch

Pattern:
  - db.table<T>('n').toArray()      → scopedForModule<T,string>('mod','n').toArray()
  - db.table<T>('n').orderBy('k').toArray() → same, replacing .toArray()
                                              with .sortBy('k') so the sort
                                              runs in-memory on the scope-
                                              filtered result

Also adds scopedAnd() to the scope barrel — wraps an existing indexed
Collection (e.g. `.where('date').aboveOrEqual(x)`) with the scope filter
via Collection.and(). Lets indexed queries keep their index hit while
still honouring scope. ~27 remaining db.table<>.where() calls will move
to scopedAnd() in a follow-up once the active-space-indexed compound
indexes land.

Visibility filtering (applyVisibility) is opt-in: the calendar/todo/
notes/contacts pilots call it; the mass-migrated modules skip it until
private records actually show up in a shared space. The default
visibility='space' makes it a no-op anyway — worth adding later when
records with visibility='private' exist in practice.

Type-check: 0 errors across 7143 files.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:42:06 +02:00
Till JS
b13eb449b3 docs(broadcast): plan newsletter / broadcast module
MVP scope: campaigns CRUD, audience filter from contacts, Tiptap editor,
bulk-send via mana-mail extension, per-recipient tracking (open/click/
unsubscribe), DSGVO-compliant footer, DNS-check.

Key decisions made up-front:
- Tracking endpoints live in mana-mail (public, token-HMAC signed) —
  not in apps/api, because mana-mail already owns SMTP + auth plumbing
- Per-recipient state stays Postgres-only; no Dexie mirror (could be
  millions of events for big lists, no cross-device benefit)
- Tiptap over Unlayer/Lexical: MIT, Svelte wrapper exists, extension-
  based so bundle stays lean via tree-shaking
- juice for CSS-inlining runs server-side — keeps the client bundle
  light and concentrates email-compat knowledge in one place
- Explicitly NOT zero-knowledge compatible; server needs plaintext
  recipient lists to send. Warning in onboarding.
- 10 milestones, ~17 days MVP. M1-M4 builds the core send path,
  M5-M8 adds tracking + DSGVO + deliverability.

Related: docs/reports/clubdesk-vs-mana-comparison.md §7.2 Paket D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:32:53 +02:00
Till JS
8e840046e6 fix(invoices): add scopedForModule import after Spaces bulk-migration
Bulk-migration script swapped db.table() → scopedForModule() in queries.ts
but the import line wasn't added, leaving the file broken. Restores type-
check by importing from \$lib/data/scope (same path calendar/contacts/todo
use).

Invoice reads now flow through the Spaces scope wrapper like other
migrated modules — queries only return rows from the active space.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:16:24 +02:00
Till JS
394aa79328 feat(invoices): close Phase-2 gaps — finance cross-link + structured addresses
Three items from docs/plans/invoices-module.md §"Offene Punkte" that
actually block real-world dogfooding:

1. Bezahlte Rechnung → Finance-Einnahme

  - financeStore.upsertTransactionFromInvoice(): deterministic id
    (invoice-tx-{invoiceId}) so marking the same invoice paid twice
    updates instead of duplicating. Uses table.put for the upsert.
  - invoicesStore.markPaid() calls it after the status transition,
    decrypts to get the gross + snapshot, converts minor→major for
    the finance row, formats description as "Rechnung {number} — {client}".
  - Best-effort: the call is try/catched so the invoice write (the
    thing the user initiated) never fails because of a finance bridge
    hiccup. Logs a warning instead.
  - Multi-currency caveat: finance's bare-number model loses the
    currency — documented in the upsert helper's comment. Works for
    single-currency freelancers (the 95% case).

2. Strukturierte Adressen für QR-Bill

  - LocalInvoiceSettings gains senderStreet/Zip/City/Country (nullable,
    so existing rows don't need a migration). Encryption registry
    updated to cover the new fields — same sensitivity tier as the
    legacy senderAddress blob.
  - InvoiceClientSnapshot gains street/zip/city/country, same shape
    as Debtor.
  - qr-bill.buildQRBillData prefers structured fields; falls back to
    parseAddress(senderAddress) for users who haven't touched the new
    settings form. Same preference chain on the client/debtor side.
  - PDF header + DetailView recipient block prefer structured too —
    stays in lockstep with what the QR-Bill reads.
  - SenderProfileForm replaces the single textarea with four labeled
    inputs. Legacy free-text address moves behind a <details> as a
    "weird edge case" escape hatch (Postfach, c/o etc.).
  - ClientPicker: same split, with contacts-source mapping using
    structured fields directly (contacts already have street/postalCode/
    city so no info loss).
  - Three new qr-bill tests cover the preference order: structured
    wins, legacy falls back, malformed snapshot omits debtor.

3. MODULE_REGISTRY.md

  - Added `invoices` under "Finanzen" with the cross-link note.

Tests: 48/48 green (up from 45), 0 type errors. Open Phase-2/3 items
still parked: camt.053 bank reconciliation, number-sequence multi-
device collision, unfreezing the paid→void edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:58:18 +02:00
Till JS
3194180efb docs(plans): mark shared-llm tool-call integration as deferred
B1 (token usage) and B2 (server-iteration auto-execution) shipped in
the follow-up session. B3 — extending the LlmBackend interface with
tool-call passthrough and wiring both runners through the orchestrator
instead of direct-fetch — was scoped out after honest re-evaluation:

- Browser-local Gemma can't do tool-calling reliably, so the tier-
  fallback value is low (the tool-tier collapses to mana-server/cloud
  anyway).
- BYOK/cloud routing via mana-llm proxy is functionally equivalent
  between direct-fetch and orchestrator paths.
- ~6 h of work across 8 files with no concrete user-facing unblock.

Kept the entry point documented for whenever a use-case actually
needs tier-routing of planner calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:36:07 +02:00
Till JS
728027c478 feat(ai): auto-execute server-planned iterations on the client
When mana-ai plans a mission tick in the background, it writes an
iteration with source='server' and plan[].status='planned' — the
server itself has no Dexie access, so those planned tool_calls have
to run on the user's device. This commit adds the missing half.

- server-iteration-executor.ts subscribes via Dexie liveQuery to
  server-sourced iterations with planned steps. For each one it
  reconstructs the AI actor (mission + agent + iteration), runs every
  step through executeTool, and writes the result status (approved /
  failed) back into the iteration.
- Idempotency: a new local-only Dexie table
  `_serverIterationExecutions` (v30) marks iterations we've already
  run, so sync replays and page reloads don't re-execute. Also guarded
  by an in-flight Set because liveQuery fires rapidly during the
  execution's own writes.
- Wired into (app)/+layout.svelte alongside startMissionTick — lives
  for the whole session, stops on teardown.

This is the mirror of the old server-iteration-staging.ts but direct-
execute instead of proposal-stage — no manual approval step. Same
user-facing behaviour as foreground mission runs: they show up in the
Workbench Timeline, revertable per iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:34:52 +02:00
Till JS
6d8637b837 feat(spaces): migrate todo/notes/contacts to scoped-db + add scopedGet
Three more modules now use the scope wrapper. Pattern matches the
calendar pilot:

  db.table<T>('X').toArray()            → scopedForModule<T>('mod','X').toArray()
  db.table<T>('X').orderBy('k').toArray → scopedForModule<T>(...).sortBy('k')
  db.table<T>('X').get(id)              → scopedGet<T>('X', id)

Added scopedGet() to the scope barrel — a primary-key fetch with a
post-read scope check so URL-manipulated deep links can't peek at
records from another space. Dexie's fast-path index read still happens;
the scope check is one field comparison on the single row.

Modules migrated:
- todo/queries.ts: useAllTasks, useAllBoardViews, useAllReminders,
  useAllProjects (4 queries; sortBy replaces orderBy-via-index)
- notes/queries.ts: useAllNotes (list), useNote (by id via scopedGet)
- contacts/queries.ts: useAllContacts

goals module lives in companion/goals with a different layout (not a
standard modules/*/queries.ts) — skipped this pass, will migrate in a
targeted follow-up.

Scope + visibility filters run BEFORE decrypt where possible so the
vault-locked UI path stays cheap: plaintext spaceId + visibility + deletedAt
metadata filters the decrypt workload before crypto gets invoked.

Performance note: sortBy() is an in-memory O(n) sort. Fine for a user's
task list, but if a hot path surfaces (e.g. a thousands-of-tasks view),
we add a [spaceId+order] compound index in a follow-up Dexie version.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:26:01 +02:00
Till JS
dc1a0a65fb docs(invoices): mark all milestones done, list Phase-2/3 open items
Quick status sync after M8. M1–M8 all landed; what's left are the
Phase-2/3 items (multi-device number collision, structured address
schema, finance cross-link, camt bank reconciliation) and the
Spaces-SSR-unblock-then-dogfood step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:23:02 +02:00
Till JS
a4bc7d2ee3 feat(invoices): M8 AI tools — create/mark_paid/list/stats
The last open item from the plan. Missions can now draft invoices from
chat context, mark customer payments, and read status for autonomous
follow-up cadences.

Tool catalog (packages/shared-ai/src/tools/schemas.ts)
- create_invoice (propose) — clientName + lines[] + currency + due
- mark_invoice_paid (propose) — by id, optional back-dated paidAt
- list_invoices (auto) — with status + limit filter
- get_invoice_stats (auto) — open/overdue/YTD per currency

Had to widen the tool-parameter type vocabulary so create_invoice can
declare lines as a typed array. Touched three places:
- ToolSchema-side: the catalog's `type` string is already free-form so
  'array' / 'object' just pass through
- ModuleTool-side (apps/mana/apps/web/src/lib/data/tools/types.ts): added
  'array' | 'object' to the union so TS doesn't narrow the executor's
  param signatures
- function-schema translator (packages/shared-ai): mapParamType +
  JsonSchemaProperty both gained the two new types; the catalog-typo
  guard test now uses 'fruit' as its sentinel (array no longer unknown)

Executor (apps/mana/apps/web/src/lib/modules/invoices/tools.ts)
- coerceLines accepts either a real array or a JSON-stringified array
  (planners vary), skips malformed entries, converts major→minor units
- create_invoice pulls the generated number back from Dexie so the
  success message shows "Entwurf 2026-0042 …" — the user recognises it
- mark_invoice_paid normalises YYYY-MM-DD → ISO so the store's timestamp
  invariant (ISO throughout) stays intact
- list_invoices derives overdue on read (consistent with useAllInvoices),
  returns major-unit amounts so the LLM reasons in user-facing numbers
- get_invoice_stats returns counts + open/overdue/YTD per currency

Registration: invoicesTools added to tools/init.ts. mana-ai drift guard
is happy (41/41 green); webapp + shared-ai type-check 0 errors; full
invoice test suite 59/59 green.

Closes: docs/plans/invoices-module.md §M8. All plan milestones now DONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:22:20 +02:00
Till JS
0d613e1846 feat(ai): thread TokenUsage through runPlannerLoop → mana-ai budget
Carries per-round token counts from the mana-llm response body
(prompt_tokens + completion_tokens) back through LlmCompletionResponse
→ PlannerLoopResult. The loop sums across rounds and exposes a single
aggregate on result.usage.

Lets mana-ai's tick re-activate per-agent daily-token budget tracking
— tokensUsed was stubbed to 0 in the migration commit (6) because the
loop didn't surface usage yet. Now recordTokenUsage + agentTokenUsage24h
get real numbers again, and the mana_ai_tokens_used_total Prometheus
counter is accurate.

Additive only: consumers without usage needs ignore the new field,
and providers that don't return usage produce zeros (not undefined —
the loop still exposes the object so downstream branches stay trivial).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:21:34 +02:00
Till JS
b878ecfe1c feat(spaces): Space-Switcher + Create-Dialog in (app) layout
First user-visible surface for the Spaces foundation. Two components:

SpaceSwitcher (header dropdown)
- Shows the active space name + type badge
- Opens a dropdown listing all user's spaces with per-type color chips
  (brand / club / family / team / practice / personal)
- Click on a space → /organization/set-active + full page reload so
  every liveQuery re-evaluates against the new active space
- "+ Neuer Space" entry at the bottom opens the Create dialog

SpaceCreateDialog (modal)
- Type picker with description per type (excluding personal — that one
  is auto-created at signup and never chosen manually)
- Name input + live slug preview (same slugifier as the server)
- Conditional fields: voiceDoc for brand/club, uid + legalEntity for
  brand/club/practice
- POSTs to /api/auth/organization/create with metadata.type, then
  /set-active and reload. beforeCreateOrganization hook rejects
  malformed metadata server-side.

Placement: compact bar at the top of the (app) max-w-7xl wrapper, only
rendered when authenticated. Zero changes to PillNavigation so the rest
of the nav surface stays untouched.

Reactivity note: the switcher full-reloads on set-active because the
scoped-db wrapper doesn't yet invalidate liveQueries on active-space
change. A reactive-invalidation path can replace the reload once the
wrapper is used across enough modules to make the UX friction matter.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:16:47 +02:00
Till JS
871a1c3bba feat(spaces): wire active-space boot into (app) layout
Calls loadActiveSpace() + reconcileSentinels() in the Phase-B critical
boot block, right after the user identity is bound to the ambient actor
and before sync starts. This means:

- Pending-change rows pushed to mana-sync carry the real organization
  id, not the `_personal:<userId>` sentinel the v28 migration uses
  as a placeholder.
- Sentinel records (written pre-boot or by the v28 upgrade on an
  existing db) get rewritten to the real personal-space id in a single
  pass once Better Auth responds.
- The scope wrapper in module queries now partitions by the active
  space instead of degrading to sentinel-only filtering.

Failure is non-fatal — an offline boot or a Better Auth hiccup just
means the sentinel path stays live and the next boot retries. A count
log surfaces the reconciliation count so migrations are visible in
devtools.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:10:46 +02:00
Till JS
76060b0632 feat(invoices): M3 logo upload — embed in PDF header
Completes the Settings polish item left open after M2.

pdf/logo.ts
- loadLogo(mediaId): fetches the large variant from mana-media, sniffs
  content-type to pick 'png' vs 'jpg', returns null on any failure so
  the PDF still renders without a logo
- uploadLogo(file): multipart POST to /api/v1/media/upload with
  app=invoices, returns the new mediaId (or throws a user-facing msg)
- logoPreviewUrl(mediaId): thin helper so the settings form doesn't
  have to know the media-URL lookup pattern

Renderer wiring
- loadLogo runs in the same Promise.all as font embedding so it doesn't
  add a serial wait
- embedPng / embedJpg based on the sniffed kind; errors degrade silently
- renderHeader takes a PDFImage|null and, when present, draws it top-
  left above the sender name, max 25mm × 45% content-width, aspect
  preserved, 3mm breathing room below

Settings UI (SenderProfileForm)
- Logo slot at the top of the Absender section: preview when set,
  "Ersetzen" / "Entfernen" actions; "+ Logo hochladen" drop-style
  button when empty
- Upload persists immediately (no separate "Speichern" click for logo
  changes) — keeps the interaction one-handed
- Accepts PNG / JPEG; invalid types rejected client-side before the
  network round-trip

Closes one of the open items from docs/plans/invoices-module.md §M3.
Next open: M8 AI-tools (create_invoice / mark_paid / list / stats).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:06:40 +02:00
Till JS
5b7564b3a4 test(ai): promote MockLlmClient to a shared @mana/shared-ai export
The runPlannerLoop test file and the webapp's mission-runner test each
had their own inline scripted LLM mock — same interface, diverged
slightly. Consolidates into packages/shared-ai/src/planner/mock-llm.ts
and re-exports from the package root so any consumer can drive the
loop deterministically.

Both existing test files now use the shared client. 5 + 3 tests pass,
44 total in shared-ai still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:05:46 +02:00
Till JS
e10c2436a6 feat(spaces): thread space_id through mana-sync protocol + storage
Server-side:
- sync_changes gains a nullable space_id TEXT column + partial index
  on (user_id, space_id, app_id, created_at) WHERE space_id IS NOT NULL.
- RecordChange takes spaceID as a first-class parameter; *string so
  empty strings land as real SQL NULL and the partial index skips them.
- ChangeRow + all three SELECTs (GetChangesSince, GetAllChangesSince,
  StreamAllUserChanges) propagate space_id through to clients.
- changeFromRow surfaces SpaceID on the wire Change shape.
- New extractSpaceID helper reads the incoming payload — prefers top-
  level spaceId, falls back to data.spaceId (inserts) or
  fields.spaceId.value (updates). Tolerates pre-v28 clients.
- 6 Go tests cover the helper + round-trip.

Client-side:
- PendingChange gains an optional spaceId.
- Dexie creating hook stamps spaceId from the active record onto the
  pending-change row (already set by the v28 scope hook).
- Dexie updating hook reads spaceId from the pre-update record and
  stamps it on the pending-change so updates carry space context even
  though spaceId itself is immutable and never in `fields`.
- buildChangeset forwards spaceId to the server.

Explicitly NOT in scope this pass:
- RLS remains user_id-scoped; multi-member shared-space reads need a
  second policy that joins against auth.members. Follow-up once shared
  spaces are actually used — today everything is personal.
- Subscription fan-out is still per-user; fan-out to all members of a
  shared space is part of the same follow-up.

Go tests: 6/6 pass. Web type-check clean (0 errors across 7139 files).

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:53:14 +02:00
Till JS
9f7d2f24b3 feat(companion): chat on runPlannerLoop with native function calling
The companion chat had its own ad-hoc 3-round tool-calling pipeline:
build a system prompt with tool descriptions, ask the LLM to emit
```tool JSON blocks, regex-extract, execute, feed back the result as
a synthetic user message. Same fragility class as the old text-JSON
planner — and now unnecessary since mana-llm speaks native function
calling.

Migrates companion/engine.ts to the shared runPlannerLoop, same as
the mission runner (commit 5a) and the server tick (commit 6). Tools
go to the LLM as proper function-schemas; tool_calls come back
structured; the executor runs them directly under USER_ACTOR.

Extends shared-ai/planner/loop.ts with an optional priorMessages[]
input field so the chat can preserve multi-turn history between
turns (missions don't need this and leave it empty).

Deletes the old llm-tasks/companion-chat.ts LlmTask wrapper. Nothing
else imported it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:45:33 +02:00
Till JS
80dbb3b3b6 feat(spaces): migrate calendar module to scoped-db wrapper (pilot)
First module to consume the scope layer — proves the model end-to-end
on a real query path.

Changes in calendar/queries.ts:
- db.table('calendars')   → scopedForModule<LocalCalendar>('calendar', 'calendars')
- db.table('timeBlocks')  → scopedForModule<LocalTimeBlock>('calendar', 'timeBlocks')
- db.table('events')      → scopedForModule<LocalEvent>('calendar', 'events')
- applyVisibility() wrapper runs on each read to drop private records
  authored by other members of a shared space.

Scope wrapper tweaks:
- getInScopeSpaceIds is now lenient during boot: if no active space has
  loaded yet, falls back to the user's personal sentinel so sentinel-
  stamped records from the v28 migration still render. Returns [] only
  when fully unauthenticated, which yields an empty-match filter.
- applyVisibility is no longer generic-constrained — T is inferred
  exactly as the input type; visibility/authorId are read via runtime
  duck-typing so arbitrary record shapes pass through cleanly.

Known follow-ups:
- Root-layout bootstrap (load active space + reconcile sentinels on
  login) is intentionally not wired up yet — needs a separate pass on
  the already-crowded (app) layout to avoid collateral damage.
- Four legacy tables (conversations, documents, spaceMembers,
  memoSpaces) carry a pre-existing `spaceId` field that points to the
  older context-space concept, not our multi-tenancy space. Renaming
  those to contextSpaceId is a tracked follow-up in the RFC — calendar
  is unaffected.

Plan: docs/plans/spaces-foundation.md (updated with the legacy-spaceId
note + lenient-scope rationale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:42:10 +02:00
Till JS
1cd559ca34 feat(mana-ai): server runner on runPlannerLoop, drops text-JSON parser
Migrates the background tick from buildPlannerPrompt + PlannerClient +
parsePlannerResponse to the shared runPlannerLoop with native function
calling. Structurally identical to the webapp runner (commit 5a) —
same catalog, same compact system prompt, same multi-turn chat.

Server-specific twist: the ``onToolCall`` callback is a no-op stub
(returns {success:true, message:'recorded — pending client
application'}). The server has no Dexie access, so it can't actually
execute writes; instead it captures the LLM's chosen tool_calls and
writes them as PlanStep entries on the iteration. The user's client
picks up those planned steps on sync — same shape as before, just
sourced from the LLM's native tool_calls instead of a regex-extracted
JSON block.

Scope trimmed by the SERVER_TOOLS filter: only propose-default (write)
tools go to the server planner. Read-only tools (list_*, get_*) are
hidden because stubbing a response would let the LLM hallucinate that
it saw real data. Read-then-act chains stay with the foreground
runner, which has a real executor.

Deleted: planner/client.ts (old PlannerClient; replaced by
planner/llm-client.ts). Drift guard in tools.ts collapses into a
SERVER_TOOLS = AI_TOOL_CATALOG.filter(propose) derivation — no more
hand-maintained duplicate list; the contract test now asserts the
inverse round-trip against AI_PROPOSABLE_TOOL_SET.

TODO (follow-up): token usage tracking is temporarily set to 0 because
runPlannerLoop doesn't expose per-message usage yet. Budget
enforcement on the server is effectively disabled until the loop
returns that data — the webapp runner is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:39:20 +02:00
Till JS
2ee3a1a93a feat(invoices): M7 dashboard widget + tests + plan status
Adds the missing bits that turn M1–M6 into a coherent shippable
product rather than a pile of commits.

Dashboard widget (M7)
- InvoicesOpenWidget.svelte: open + overdue totals in the primary
  currency, top-3 oldest overdue with "X Tage überfällig" under each,
  empty-state CTA for first-time users
- Registered as `invoices-open` in WIDGET_REGISTRY and the component
  map. Default size medium, no requiredBackend (local-first, no API)
- Fixed pre-existing test gap: validBackends list was missing 'body'
  (body-stats widget has been failing silently) — added so the check
  protects against drift for real

Tests (45 total, all green)
- totals.test.ts (9): computeLineTotal with discount+vat, grouping
  invariant (breakdown sums == invoice totals), rounding edges
- pdf/qr-bill.test.ts (17): generateSCORReference stability +
  spec-validity via swissqrbill's own isSCORReferenceValid, buildQRBillData
  eligibility gates (currency, IBAN, address, amount), CH + DE address
  parser paths, referenceNumber-preferred-over-regen invariant
- mail-template.test.ts (12): subject/body composition (with/without
  subject, CHF vs EUR QR-hint, empty recipient fallback), mailto
  spaces-as-%20 patch, looksLikeEmail edge cases

Plan (docs/plans/invoices-module.md)
- Updated with commit SHAs per milestone, testing status, and the
  explicit list of open items (Logo-Upload, AI-Tools, sync collision,
  structured addresses, finance cross-link, camt bankabgleich) so the
  next coder knows exactly what's parked where

Unresolved: browser smoke test couldn't run — SSR is broken for all
module routes in the current tree (pre-existing, likely from the
parallel Spaces refactor; /library, /todo, /contacts all return 500
the same way). Unit tests + clean bundle build (M4) + type-check are
the coverage we have.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:38:18 +02:00
Till JS
2d15684ed4 refactor(webapp): delete proposal infrastructure + ai-plan legacy wrappers
Runner no longer creates proposals (commit 5a) and no module renders
the inbox (commit 5b), so the supporting code is dead. This commit
deletes it.

Removed:
- data/ai/proposals/ (types, store, queries + tests) — the entire
  Proposal model + createProposal/listProposals/approveProposal API.
- components/ai/AiProposalInbox.svelte — orphaned after commit 5b.
- data/ai/missions/server-iteration-staging.ts + its test — the bridge
  that turned server-produced iterations into local proposals. Server
  iterations will land with executed steps directly once commit 6
  migrates the server runner.
- data/ai/missions/planner/ — all webapp re-exports of the old
  buildPlannerPrompt / parsePlannerResponse / AiPlanInput types. The
  new runner imports its types directly from @mana/shared-ai.
- llm-tasks/ai-plan.ts — the old LlmTask that wrapped the text-JSON
  request/parse cycle for the LlmOrchestrator. Replaced by the direct
  mana-llm client in missions/llm-client.ts.

Updated:
- data/database.ts — v29 drops the `pendingProposals` table (passing
  null to .stores() deletes it on next open). Safe because nothing is
  live.
- routes/(app)/+layout.svelte — no more startServerIterationStaging /
  stopServerIterationStaging in the bootstrap/teardown pair.
- data/ai/missions/types.ts — strips the planStepStatusFromProposal
  bridge helper (proposals don't exist any more).
- data/ai/missions/input-resolvers.ts — imports ResolvedInput from
  @mana/shared-ai directly.
- data/tools/executor.test.ts — the proposal-staging test block is
  rewritten to match the new semantics: auto and propose both execute
  inline, only deny refuses.

Net: ~1100 LoC removed, 0 added. Type-check green, 15 tests pass
across executor + runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:30:13 +02:00
Till JS
08b7ac16bf feat(invoices): M6 send-by-mail flow — mailto + PDF download + confirm
Closes the primary workflow: the user can now send an invoice from Mana
in three clicks.

mana-mail's JSON send API can't carry attachments yet, so we hand off to
the user's default mail client via mailto: and download the PDF
separately. The user attaches the PDF manually; a two-step modal keeps
the draft → sent transition honest.

mail-template.ts
- buildInvoiceMailDraft(invoice, settings): German template with
  recipient name, amount, due date, QR-bill hint for CHF, sender sign-
  off from settings.senderName
- mailDraftToMailto(): URLSearchParams + patch `+` → %20 so macOS Mail /
  Outlook / Thunderbird / Apple Mail iOS all preserve spaces correctly
- looksLikeEmail(): permissive inline validator for the recipient field

SendModal.svelte
- Compose step: editable to / subject / body, warning if recipient isn't
  a well-formed email (non-blocking — user can fix in their mail client)
- "Öffnen & herunterladen" triggers the PDF download, then navigates
  window.location.href to the mailto: URL (window.open gets blocked by
  popup blockers; location navigations survive)
- Handoff step: two ✓ rows + explicit instruction to attach the PDF,
  "Rechnung wurde versendet" button that calls markSent()
- Backdrop click + Escape both close; role="dialog" on the modal itself
  with tabindex so screen readers land correctly

DetailView wires "Per Mail versenden" as the new primary action for
drafts, keeping "Als versendet markieren" as a secondary path for users
who send outside Mana (post, fax, in-person).

Plan: docs/plans/invoices-module.md §M6.
Next: M3 logo-upload / M7 dashboard widget / M8 AI-tools — solo-MVP is
now usable end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:27:34 +02:00
Till JS
c34c75517c feat(spaces): scope layer — active-space store + scoped-db wrapper + visibility
Adds the client-side scope primitives that sit between module code and
Dexie so every query is filtered by the user's active Space:

  lib/data/scope/
  ├── active-space.svelte.ts   reactive active-space state; loads via
  │                             Better Auth's organization/get-active-member
  │                             and auto-activates personal on first boot
  ├── bootstrap.ts              reconcileSentinels() — rewrites every
  │                             `_personal:<userId>` placeholder from the
  │                             v28 migration to the real space id once
  │                             Better Auth responds
  ├── scoped-db.ts              scopedTable / scopedForModule — filter-
  │                             based scope enforcement. assertModuleAllowed
  │                             blocks disallowed modules per space-type
  │                             (e.g. mood in a brand space)
  ├── visibility.ts             applyVisibility / isVisibleToCurrentUser —
  │                             hides private records not authored by the
  │                             current user, even inside a shared space
  └── index.ts                  barrel export for consumers

Wrap accepts sentinel spaceId alongside the real id during the bootstrap
window so records written between v28 landing and the first reconcile
don't vanish from the UI.

No module uses this yet — the calendar pilot migration in the next
commit is the first consumer and validates the whole model.

10/10 unit tests pass. The fetch- and Dexie-backed functions
(loadActiveSpace, reconcileSentinels, scopedTable) are integration-only
and covered as the pilot migration lands.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:24:43 +02:00
Till JS
78bfea452a refactor(webapp): drop AiProposalInbox usages from 9 module pages
All 9 module pages that rendered the proposal inbox lose that block.
Since the runner now executes tool calls directly (commit 5a), no
proposals are ever staged — the inbox would just render an empty list
forever.

Removed from: /todo, /calendar, /places, /drink, /food, /news, /notes
module routes plus the goals and ai-missions ListViews. The mission
detail view no longer embeds a "Vorschläge zur Review" section; the
iteration cards with their executed tool_calls are the record now.

The AiProposalInbox component itself survives this commit so the
proposals store and staging code that still imports it keep compiling.
Next commit deletes the whole proposal infrastructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:18:54 +02:00
Till JS
698ffe797c feat(spaces): add spaces pg schema — credentials + module_permissions
Groundwork for server-side Space extensions that must NOT live in Dexie:
  - spaces.credentials         — per-space OAuth tokens, API keys, SMTP
                                 configs. Access tokens are stored
                                 encrypted at rest with the service KEK.
  - spaces.module_permissions  — role × module read/write/admin overrides
                                 on top of the SPACE_MODULE_ALLOWLIST
                                 defaults.

Both tables FK to auth.organizations with ON DELETE CASCADE so deleting
a space drops its credentials and permission overrides automatically.

RLS is intentionally deferred — enabling it now would lock out services
that don't yet pass space context. A follow-up migration turns it on
after mana-api speaks the Spaces protocol end-to-end.

To apply locally: bun run db:push in services/mana-auth, or psql -f
sql/004_spaces.sql against the mana_platform DB.

No runtime code reads these tables yet — they're the scaffolding that
Task-8 (mana-sync) and the eventual social-relay/clubs modules will
consume.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:13:33 +02:00
Till JS
da1bb2d6e9 feat(spaces): auto-create personal space on signup via Better Auth hook
Wires databaseHooks.user.create.after to call createPersonalSpaceFor,
which provisions a Better Auth organization of type='personal' with the
user as owner. Every signup now produces a usable default space — no
UI code needed to bootstrap it.

Details:
- Slug derived from email local-part, lowercase, alphanumerics + hyphens,
  max 30 chars, random fallback if nothing usable remains.
- Reserved-slug list (me/admin/api/auth/…) blocks system-route clashes.
- Collision resolver appends -2, -3, … up to 999 before falling back to
  a random suffix. Tests cover both the DB-taken and reserved-slug cases
  via an injectable SlugTakenLookup (no DB needed for unit tests).
- Idempotent: if a personal space already exists for the user, returns
  it instead of creating a duplicate. Guards against retry double-signup.
- Failure propagates — an orphan user without a personal space is worse
  than a retry-able signup error.

Existing dev users will need a backfill or a re-provisioning of the dev
DB — new users are unaffected.

12 tests pass (23 total across the spaces module).

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:10:26 +02:00
Till JS
5af96bfeff feat(webapp): mission runner on native function calling, tools execute directly
The runner now drives runPlannerLoop from @mana/shared-ai: the LLM
emits native tool_calls via mana-llm's tools passthrough, we execute
each call immediately under the AI actor, and feed the result back as
a tool-message for the next turn. The reasoning loop still runs up to
5 rounds (same budget as before) but needs no hand-rolled re-prompting
because the SDK-level tool-message exchange does that for us.

Tool execution is direct — no Proposal staging. The executor's propose
branch collapses into auto (proposal store calls stay in place for
legacy consumers this commit doesn't touch; those go next). Agent-
level deny still refuses and surfaces the refusal as a tool-message
the LLM can react to.

New surface:
- missions/llm-client.ts — mana-llm HTTP adapter conforming to shared-
  ai's LlmClient. Posts /v1/chat/completions with tools + tool_choice,
  converts OpenAI-shape tool_calls back to our ToolCallRequest shape.
- runner.ts shrinks from ~770 to ~410 lines — pre-step research,
  guardrails, agent scope, timeout, cancel, debug capture all kept.
- debug.ts stores rawMessages[] (shared-ai ChatMessage) instead of
  plannerCalls[]/loopSteps. AiDebugBlock renders the chat transcript.
- available-tools.ts returns ToolSchema[] directly so the runner can
  hand the array to runPlannerLoop unchanged.
- setup.ts wires createManaLlmClient() instead of aiPlanTask +
  llmOrchestrator. The old aiPlanTask + planner/ re-export files
  remain orphaned for the next commit to delete.

Test shape: MockLlmClient scriptable via enqueue-style turns. Three
cases cover happy path, empty-plan stop, and tool-failure propagation.

Dead-but-still-compiling afterwards: the proposals folder, the
AiProposalInbox component + its 9 call-sites, server-iteration-
staging.ts, ai-plan.ts, the legacy planner/ wrappers, and the old
buildPlannerPrompt/parsePlannerResponse exports in shared-ai. These
go in commits 5b/5c/5d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:08:58 +02:00
Till JS
5af23d30b6 feat(invoices): M5 Swiss QR-Bill — SCOR reference + PDF overlay
Adds swissqrbill integration so CHF/EUR invoices get the Zahlteil (payment
part) rendered in the bottom 105mm of the last page.

Integration path (pdf/qr-bill.ts)
- swissqrbill/pdf targets PDFKit, not pdf-lib; so we use swissqrbill/svg,
  rasterise the SVG to PNG in a browser canvas at ~300 DPI target, then
  embed the PNG via pdf-lib's embedPng
- Eligibility gate via QRBillError: validates currency (CHF/EUR), IBAN
  (swissqrbill's isIBANValid), parseable sender address, positive amount
- Address parser: heuristic for two-line Swiss/DE addresses
  (street + number on line 1, "{zip} {city}" on line 2). Fails loud —
  the renderer silently omits the Zahlteil and the UI surfaces a warning
- SCOR reference (ISO 11649) generated from invoice.number as payload,
  truncated to 21 chars, checksum via swissqrbill/utils. Persisted on
  invoice.referenceNumber at create time so it stays stable across edits
  and re-renders

Renderer wiring
- renderInvoicePdf(..., { includeQRBill?: boolean }) — defaults true
- QRBillError is caught and absorbed; other errors propagate
- qrBillStatus(invoice, settings) — cheap pure check, returns
  { ok: true } or { ok: false, message, reason } for UI hints

DetailView
- Warning banner above PDF preview when QR-Bill is not eligible, with
  a "Einstellungen öffnen →" deep link
- Preview iframe now shows the PNG-embedded Zahlteil on CHF/EUR
  invoices

Addressed §"Offene Fragen" from the plan
- QR-Bill-Scope: CHF + EUR per swissqrbill spec, not USD
- Address parsing: heuristic now, structured fields to be added in M7
  (tracked in renderer warning path — user sees exactly what's missing)

Plan: docs/plans/invoices-module.md §M5.
Next: M6 send flow (open mail compose with PDF attached).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:07:35 +02:00
Till JS
166d6c6ffb feat(spaces): validate space metadata on Better Auth organization hooks
Moves the canonical SpaceType + SPACE_MODULE_ALLOWLIST to @mana/shared-types
(framework-free) so the Bun services can consume them without pulling in
Svelte. shared-branding keeps only the UI-facing labels and descriptions
and re-exports the canonical types for frontend convenience.

Wires two Better Auth organization hooks in mana-auth:
- beforeCreateOrganization asserts metadata.type is a valid SpaceType,
  rejecting the create with a BAD_REQUEST otherwise.
- beforeDeleteOrganization rejects deletion of the personal space.

Covered by bun tests (11 assertions) for the helper module.

No migration and no schema change — type lives in the existing
organization.metadata jsonb column.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:05:38 +02:00
Till JS
9d69e4419d docs(claude-md): document \validate:all\ + \test:coverage\ in Quick Start
The Quality block listed build / type-check / format but not the two
audit-adjacent commands added this session. \`validate:all\` is the
local mirror of CI's validate job (turbo recursion + pgSchema + crypto
registry) and is the right pre-push gate; \`test:coverage\` emits the
lcov + json-summary artifacts that CI uploads.

Both were already documented in their per-topic guidelines
(authentication.md, database.md, testing.md) — this just surfaces them
in the root Quick Start so contributors don't have to know which
guideline mentions which command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:03:55 +02:00
Till JS
3179fa10cf fix(page-shell): keep header title on one line with ellipsis
Narrow pages (e.g. AI Workbench at 320px) wrapped the title onto two
lines because .header-left lacked min-width: 0 and .page-title had no
truncation rules. Add flex shrink + nowrap + text-overflow: ellipsis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:03:26 +02:00
Till JS
8dbc850beb chore(ci): add \validate:all\ + fix undefined \validate:monorepo\ reference
\`ci.yml\` had a \`pnpm run validate:monorepo\` step that referenced a
script defined nowhere in the repo — CI would fail at that step
whenever the validate job ran. Replacing it with a new bundled
\`validate:all\` script closes that gap and gives contributors a single
local command that mirrors what CI enforces.

- New \`validate:all\` chains the three fast repo-invariant checks
  (turbo recursion, pgSchema isolation, crypto registry) with fail-fast
  semantics. Runtime ~1s — suitable as a pre-push gate.
- \`validate:dockerfiles\` intentionally left out: its current output
  is 41 pre-existing "MISSING" warnings on two web Dockerfiles, which
  look like a validator-vs-wildcard-COPY mismatch rather than real
  issues. Keeping it as a standalone script so those can be
  triaged separately without blocking \`validate:all\`.
- ci.yml: four separate validate steps collapsed into one. The step
  rename also removes the dead \`validate:monorepo\` call.

Verified: \`pnpm run validate:all\` exits 0 in ~1s — 138 packages
scanned for turbo recursion, 727 TypeScript files for raw pgTable,
190 Dexie tables classified in the crypto registry (85 encrypted,
105 allowlisted plaintext).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:01:54 +02:00
Till JS
b249345174 feat(spaces): add space types + module allowlist as multi-tenancy foundation
Introduces SpaceType ('personal' | 'brand' | 'club' | 'family' | 'team' |
'practice') and SPACE_MODULE_ALLOWLIST as the shared-branding primitives
for the Spaces refactor that replaces the user-vs-org polymorphy with a
single tenancy primitive (Notion/Linear pattern).

Pure additive — no runtime behaviour change yet. Better Auth config,
Dexie migration, scope wrapper and rolling module migration follow in
separate commits.

Plan: docs/plans/spaces-foundation.md
Social-relay plan now defers brand storage to the Spaces primitive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:57:57 +02:00
Till JS
2dc298a796 feat(invoices): M4 PDF rendering — pdf-lib renderer + preview + download
Adds client-side PDF generation via pdf-lib (Helvetica standard fonts,
~7KB output, no font bytes shipped).

Renderer (pdf/renderer.ts)
- renderInvoicePdf(invoice, settings) → Uint8Array
- renderInvoicePdfBlob(...) → Blob for iframe / download / email attach
- Layout sections: header (sender + meta), recipient, subject, lines
  table with wrapping + description row, totals with per-rate VAT
  breakdown, notes, terms, footer
- Pagination: lines table opens a continuation page if content would
  overflow into the QR-Bill reserved area; continuation pages redraw
  the table header

Template (pdf/templates/default.ts)
- A4, margins in mm, emerald accent matching app icon
- Reserves 105mm at page bottom for the Swiss QR-Bill (M5) so the
  body never collides with that region

DetailView integration
- Live PDF preview in an iframe — re-renders when invoice.updatedAt
  changes (mutations bump the timestamp)
- Blob URLs revoked on render / unmount to avoid memory leaks
- "PDF herunterladen" button produces a Rechnung-{number}.pdf download
- Structured-data view moved behind <details> so the PDF is the primary
  surface; raw data still accessible for debugging

pdf-lib dep added to @mana/web.

Plan: docs/plans/invoices-module.md §M4.
Next: M5 swissqrbill (Zahlteil in the reserved region).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:57:30 +02:00
Till JS
0077752456 fix(type-check): clear the last five failures — monorepo type-check is now 76/76 green
After the mobile-app deletion unblocked \`@context/mobile\`, five more
pre-existing failures surfaced across shared packages and two services.
All were silent-masked by the postinstall \`|| true\` for months.

- **shared-ai**: \`planner/loop.ts\` imported \`ToolSchema\` from
  \`../tools/function-schema\`, which only imports (not re-exports) the
  type. Fixed to import from the source (\`../tools/schemas\`).
- **shared-logger**: \`typeof window !== 'undefined'\` blows up under
  tsconfigs that don't include the DOM lib (e.g. uload-server's
  \`bun-types\`-only config), because shared-logger is consumed via
  source import. Replaced with a \`globalThis\`-indirected check that
  compiles under any lib configuration.
- **shared-hono**: \`credits.ts\` returned \`res.json()\` directly as
  \`Promise<T | null>\`. Modern \`@types/node\` / undici types return
  \`unknown\` strictly — cast to \`T\` at the boundary so the generic
  contract is explicit.
- **uload-server**: \`routes/analytics.ts\` + \`routes/email.ts\` still
  imported \`AuthUser\` from a \`middleware/jwt-auth\` module that was
  deleted during the migration to \`@mana/shared-hono\`. Replaced with
  \`AuthVariables\` from shared-hono, which matches the actual context
  shape set by \`authMiddleware()\`.
- **manavoxel/web**: \`guestSeed\` collection entries were wrapped in
  arrow functions, but \`local-store\` expects \`T[]\` directly and
  iterates \`seed.length\` — which on a function is 0. The "guest
  seed" was silently dead; eager-evaluating \`generateGuestWorld()\`
  once and sharing the result fixes both the type and the runtime.

Verified: \`pnpm run type-check\` from the repo root now exits 0 —
76/76 tasks successful, no failures. First fully green state since
well before the postinstall \`|| true\` was introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:53:07 +02:00
Till JS
a44e3df1d0 refactor(mana): remove post-signup onboarding wizard
Drop the 8-step wizard (Welcome, Profile, Context, Apps, AI-Tier, Sync,
Credits, Complete) in favor of contextual, per-module intros — todo and
news already own their first-run flows, and the workbench empty state
handles the initial surface for new users.

Removes components/onboarding/, stores/onboarding.svelte.ts, the
ONBOARDING storage key, and all trigger/render wiring in (app)/+layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:46:21 +02:00
Till JS
8d00ee0697 feat(invoices): M2 CRUD — draft lifecycle, totals, list + detail
Full draft → sent → paid flow with the supporting surface:

Data layer
- totals.ts: pure computeLineTotal / computeInvoiceTotals (testable
  without Dexie); per-line rounding then per-rate summing so the
  breakdown equals the sum-of-lines exactly
- queries.ts: useAllInvoices (live, overdue derived on read),
  useInvoiceClients, computeStats per currency, formatAmount
- stores/settings.svelte.ts: singleton with stable sentinel id,
  ensureSettings() lazy-creates, takeNextNumber() atomic via Dexie
  rw-transaction (plaintext-only fields → no crypto breaks the tx)
- stores/invoices.svelte.ts: create / update / updateLines (recomputes
  totals) / markSent / markPaid / void / duplicate / soft-delete; drafts
  only editable, paid can't be voided, sent/paid can't be deleted

Components
- StatusBadge, LinesEditor (add/remove/reorder, live per-line totals,
  minor-unit conversion on type), ClientPicker (contacts + invoice
  client book, manual entry fallback, snapshot binding),
  SenderProfileForm (sender + sequence + defaults), InvoiceForm
  (create + edit with live totals + VAT breakdown)

Views & routes
- DetailView with full action bar (edit/duplicate/mark sent/mark paid/
  void/delete), lines table, per-rate totals block
- ListView rewritten: status chips with counts, stats cards
  (open/overdue/YTD fakturiert/YTD bezahlt) scoped to primary currency,
  search, row-click to detail
- routes: /invoices/new, /invoices/[id], /invoices/[id]/edit (with
  edit-lock for non-drafts), /invoices/settings

Design notes addressed
- Number sequence atomicity: rw-transaction around plaintext fields
  guards same-device race; cross-device offline collision documented
  in settings store header as a known gap, acceptable for solo-MVP
- Edit-after-send: drafts only are editable; to revise a sent invoice,
  void and duplicate (keeps bookkeeping evidence intact)

Plan: docs/plans/invoices-module.md §M2.
Next: M3 pdf-lib renderer + M5 swissqrbill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:40:11 +02:00
Till JS
248137ec43 chore(mobile): remove 6 of 7 mobile apps — keep only memoro
Six Expo mobile apps lagged behind their web counterparts and haven't
shipped updates. Keeping them in the repo kept CI noisy (the context/
mobile type errors were only unmasked after yesterday's postinstall
fix), and they blocked other cleanup (parallel lockfile entries, dead
scripts). Removing them since the web surface under mana.how is the
active product.

Deleted (~175 MB, ~700 files):
- apps/cards/apps/mobile
- apps/chat/apps/mobile
- apps/context/apps/mobile      (the one still failing type-check)
- apps/mana/apps/mobile
- apps/picture/apps/mobile
- apps/traces/apps/mobile

Kept: apps/memoro/apps/mobile (the only actively-developed mobile app,
tied to the audio-recording native module).

Cleanup:
- Dropped 6 `dev:*:mobile` scripts from root package.json that pointed
  at the deleted apps. Other `dev:*:mobile` entries (quotes, contacts,
  calendar, mail, moodlit, finance, figgos) already pointed at
  non-existent apps before this change — out of scope, a separate
  dead-script sweep.
- Root CLAUDE.md: updated the "per-product mobile apps exist" prose
  and the repo-layout diagram to reflect the memoro-only reality.
- apps/mana/CLAUDE.md: removed the `mobile/` entry from the apps/
  layout box, noted the deletion date, and updated the tech-stack
  table to point at the memoro mobile app as the sole Expo surface.

No CI workflow or turbo.json references touched — none existed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:31:47 +02:00
Till JS
4daca8970b feat(shared-ai): runPlannerLoop + compact system prompt for function calling
Introduces the new planner pipeline both the webapp runner and the
mana-ai tick will swap onto in the next commits. Additive for now —
the legacy buildPlannerPrompt + parsePlannerResponse stay exported so
callers can migrate one at a time; they get removed once the last
consumer is gone.

- planner/loop.ts — runPlannerLoop orchestrates a multi-turn chat
  against a caller-supplied LlmClient. Tool-calls from the LLM are
  handed to an onToolCall callback and their results fed back as
  tool-messages. Parallel tool-calls in one turn execute sequentially
  to keep the message log linear for debugging. Stops on assistant
  stop, empty tool_calls, or a hard max-rounds ceiling (default 5).
- planner/system-prompt.ts — new buildSystemPrompt. ~40-line German
  system frame, no tool listing (the SDK-level tools field carries
  the schemas now), no JSON format example, no "please return JSON"
  plea. User frame renders mission + linked inputs + last 3
  iteration summaries, same as before.
- Five test cases covering the loop: immediate stop, single tool
  call with result feedback, parallel calls execute in order, tool
  failures propagate as tool-messages the LLM can react to, and
  maxRounds ceiling fires with the right stopReason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:31:01 +02:00
Till JS
2cf89ce26a feat(invoices): M1 skeleton — module registration + empty ListView
New outbound-finance module that issues invoices to clients. M1 scope:
- types, constants, collections with demo seed (not auto-loaded)
- module.config registered in module-registry
- Dexie v27 with invoices / invoiceClients / invoiceSettings tables
- encryption registry entries for all three tables (type-safe via entry<T>)
- app entry (requiredTier: alpha) + gradient icon (emerald→teal, QR corner)
- route /invoices mounts ListView with empty state

Money stored as integers in minor units (Rappen/cents) to avoid float
drift. Totals kept plaintext for liveQuery aggregation; lines encrypted
as a whole array so titles ride alongside. Settings is a singleton with
stable sentinel id so sync dedupes on it.

Plan: docs/plans/invoices-module.md. Next: M2 CRUD + number generator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:28:09 +02:00
Till JS
11f768b8e5 docs(invoices): ClubDesk vs. Mana comparison + invoices module plan
Competitive analysis of ClubDesk (reeweb ag, ~20'000 DACH clubs) with a
dual-use roadmap identifying features that benefit both clubs and general
users (freelancers/creators). First chosen step: invoices module with
Swiss QR-Bill as the CH-differentiator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:27:57 +02:00
Till JS
4523ab24e3 feat(shared-ai): toolToFunctionSchema — catalog → OpenAI function-spec
Single bridge between the AI_TOOL_CATALOG shape and the wire format every
provider (Gemini, OpenAI-compat, Ollama ≥ 0.3) speaks for native tool
calling. Keeps the catalog as the source of truth — the runner never
reads catalog entries directly; it asks this converter for function-spec
shapes to hand the LLM.

- No _rationale or wrapper-tool injection: the runner doesn't need it
  and the added schema noise would hurt planner quality.
- Throws on unknown parameter types so catalog typos (e.g. "array"
  instead of "string") fail loudly instead of coercing silently.
- Preserves enum constraints; drops the enum key entirely when absent
  so Gemini doesn't reject empty-enum function-declarations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:24:36 +02:00
Till JS
e757470cb0 feat(mana-llm): add OpenAI-style tools + tool_calls passthrough
Extends the chat-completions surface so callers can ask any provider
to call named functions and get structured tool_calls back. Wired
through all three provider adapters so the planner and companion can
switch off the fragile JSON-parsing pathway.

- Request: tools[], tool_choice, assistant tool_calls, tool-role
  messages with tool_call_id.
- Response: MessageResponse.tool_calls, Choice.finish_reason adds
  "tool_calls", DeltaContent streams tool_calls.
- Google provider: Tool(function_declarations=...) build, result
  normalised (args dict → JSON string), function_response parts on
  a user turn for tool-role messages.
- OpenAI-compat: 1:1 passthrough of the OpenAI spec.
- Ollama: /api/chat passthrough; model-level capability check via a
  TOOL_CAPABLE_OLLAMA_PATTERNS whitelist (llama3.1+, qwen2.5+,
  mistral, command-r, …) — unsupported models rejected rather than
  silently falling back to prose.
- Router: model_supports_tools() check upfront for both streaming
  and non-streaming paths; ProviderCapabilityError bubbles as 400.

No silent downgrade. Missing tool support = explicit error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:22:48 +02:00
Till JS
c612a22371 fix(type-check): unblock two more pre-existing failures
After yesterday's type-check cascade repair (c34175afa), the root
\`pnpm run type-check\` progressed through 5 more packages but still
stopped on two pre-existing failures:

- \`services/mana-media\` delivery route: \`c.body(transformedBuffer)\`
  passed a Node \`Buffer<ArrayBufferLike>\`, but Hono 4.7 types the
  body argument as \`Uint8Array<ArrayBuffer>\` (strict — no
  ArrayBufferLike). \`Uint8Array.from(buf)\` gives a clean copy with a
  fresh \`ArrayBuffer\` backing that the strict type accepts. Runtime
  cost for a handful of KB per image transform is negligible next to
  the Sharp pipeline that produced the buffer.
- \`packages/shared-llm\`: same rune issue as local-stt + local-llm —
  \`store.svelte.ts\` uses \`$state\` and transitively pulls in
  \`local-llm/src/svelte.svelte.ts\`. Plain tsc can't resolve Svelte 5
  runes. Same treatment: \`type-check\` script explicitly skips with a
  message pointing at svelte-check.

Root \`pnpm run type-check\` now reaches \`@context/mobile\`, which has
real code-level type errors (adapter shape mismatches, an RN event-
handler typing drift, and a deleted Supabase module still imported by
\`utils/supabaseTest.ts\`). Those need domain changes, not config
tweaks — out of scope for this repair pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:20:08 +02:00
Till JS
4b8fede7fc fix(mana-llm): surface Gemini finish_reason errors instead of returning ""
The google provider called response.text after a chat completion and
passed the resulting string downstream unchanged. When Gemini's content
filter, recitation guard, or max_tokens ceiling fired, response.text
quietly returned "" — which the planner then reported as "no JSON block
found", masking the real cause. Empirically this failed in 45 ms on a
simple Quiz mission.

Introduces providers/errors.py with a small ProviderError hierarchy
(Blocked / Truncated / Auth / RateLimit / Capability). google.py now
inspects response.candidates[0].finish_reason and raises the matching
structured error; the non-streaming path maps it to 422/502/429 via a
new except-branch in main.py, and the streaming path surfaces the kind
as the SSE error type. Capability is wired but not yet used — it lands
with the tool-schema passthrough in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:15:37 +02:00
Till JS
c34175afab fix(type-check): repair silently broken per-package type-check scripts
Yesterday's postinstall fix (\`d1d37749f\`) removed the \`|| true\`
guards, which in turn exposed that \`pnpm run type-check\` at the
root had been red for a long time but nobody noticed. Several per-
package scripts were genuinely broken:

- \`@mana/test-config\`: \`vitest.config.base.ts\` and \`.svelte.ts\`
  pass \`all: true\` to the coverage block. Vitest 4 removed that flag
  (including uncovered files is now the default), so tsc reports
  \`'all' does not exist in type 'CoverageOptions'\`. Removed both.
- \`@mana/credits\`: \`tsconfig.json\` include glob had
  \`"src/**/*.svelte"\`, which makes tsc try to parse .svelte files
  as TS source. It can't. Removed .svelte from include; added
  \`"exclude": ["src/web/**"]\` — the web consumer layer is checked by
  svelte-check in the apps that import it, not here.
- \`@mana/local-stt\` + \`@mana/local-llm\`: ship \`svelte.svelte.ts\`
  files that use Svelte 5 runes (\`$state\` etc.). Plain tsc has no
  rune support — \`$state\` is not a name it knows about. Both
  packages' \`type-check\` scripts now explicitly skip with a message
  pointing at svelte-check as the right tool. The rune code is still
  type-checked by svelte-check when a consumer app runs \`pnpm check\`.
- \`@manavoxel/shared\`: was missing its \`tsconfig.json\` entirely,
  so the \`type-check\` script ran tsc with no config, which dumped
  the CLI help and exited non-zero. Added a minimal bundler-mode
  tsconfig matching the pattern used by sibling packages.

\`pnpm run type-check\` now goes further than it has in months —
next failure is a real pre-existing Hono type mismatch in
\`services/mana-media/apps/api/src/routes/delivery.ts\` (Buffer vs
c.body signature), which is out of scope here and needs a proper
code fix, not a config fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:13:54 +02:00