Two fixes surfaced by the end-to-end smoke test.
1. broadcast-track.ts: inner route paths double-prefixed.
Routes were declared as '/track/open/:token' etc, then
mounted at '/api/v1/track', yielding '/api/v1/track/track/open/:token'
— every tracking endpoint returned 404. Dropped the redundant
'/track/' prefix so the full path is now
'/api/v1/track/{open,click,unsubscribe}/:token' as the
orchestrator + client both expect.
Verified with live curl:
- /track/open/BAD → 200 image/gif 42 bytes (graceful no-signal)
- /track/click/?url missing → 400 missing url
- /track/click?url=javascript: → 400 bad url
- /track/click?url=https://ok + bad token → 302 graceful
- /track/unsubscribe/BAD GET → 400 HTML
- /track/unsubscribe/BAD POST → 400 (RFC 8058)
2. shared-branding/tsconfig.json: allowImportingTsExtensions
missing. shared-types/src/index.ts uses explicit .ts
imports (intentional, for Tailwind's module resolver); any
downstream tsconfig without allowImportingTsExtensions emits
8 errors. shared-auth already had this fix — shared-branding
gets the same treatment. noEmit:true is set, so no rewrite
flag needed.
Verified: shared-branding pnpm check → 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M4 — Tags + Filter:
- queries.ts: useArticleTagIds(id) + batched useArticleTagMap(ids)
live queries against articleTagOps (the junction into globalTags).
- DetailView: TagField from @mana/shared-ui with the global tag pool +
this article's selected ids; onChange fans out through
articleTagOps.setTags, which diffs add/remove internally.
- ListView: 6 filter chips (Alle | Ungelesen | In Arbeit | Gelesen |
Favoriten | Archiv) with live counts. Archived articles are hidden
from the "Alle" view and only surface under the Archiv filter. Tag
chips render inline on each card using the batched tag map + the
global tag pool for colour lookup.
M5 — Migration + news deprecation:
- modules/articles/migrations/from-news.ts: boot-gated migration (per-
device localStorage sentinel). Reads newsArticles with type='saved',
decrypts under the newsArticles allowlist, re-encrypts under the
articles allowlist, and copies into the articles table. Status maps
isArchived→archived, isRead→finished, else unread. Source rows get
soft-deleted so the sync engine removes them from other devices.
Ran after crypto init (from (app)/+layout.svelte boot block), not
in the Dexie .upgrade() hook, because the decrypt→re-encrypt round-
trip needs Web Crypto + the master key.
- news/stores/articles.svelte.ts: removed saveFromUrl — ad-hoc URL
saves now live in the articles module.
- news/api.ts: removed extractFromUrl helper + ExtractedArticleDto.
The /api/v1/news/extract/* routes stay in apps/api for now because
news-research still hits them for RSS discovery.
- news/index.ts: dropped the extractFromUrl re-export.
- news/tools.ts: the save_news_article AI tool keeps its name (so
historic Mission iterations in the DB still resolve) but its
execute body now routes through the articles module's saveFromUrl.
- routes/(app)/news/add + /news/saved: replaced with single-shot
redirects to /articles/add and /articles respectively.
- news-research ListView + page: "Speichern" buttons now route to
the articles module and navigate to /articles/[id] on success.
Plan: docs/plans/articles-module.md. M6 (AI tools + proposal inbox),
M7 (share target + bookmarklet), M8 (highlights view + stats) open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three modules move from "dedicated route only" to "first-class
apps in the launcher". After this they show up in the AppDrawer
pill, can be pinned to workbench scenes, and get a direct URL from
the app switcher.
MANA_APPS entries added:
- agents (/agents) — AI agent management. Icon: smiling robot head
with antenna dot. violet→fuchsia gradient, status
beta, requiredTier beta.
- timeline (/timeline) — Chronological view across modules. Icon: vertical
event dots with connecting axis. amber→orange,
status beta, requiredTier beta.
Plus: broadcast's MANA_APPS entry already existed but had no URL
override, so the auto-derived /broadcast didn't match the real route
at /broadcasts. Added an APP_URL_OVERRIDES entry mapping
id='broadcast' → '/broadcasts' so the app switcher lands the user on
the right page. Icon + module.config stay singular.
Route wiring:
- /agents previously only had /agents/templates/ as a subroute. Added
/agents/+page.svelte that renders the existing ai-agents ListView
(at $lib/modules/ai-agents/), so the top-level URL works from the
AppDrawer.
- /timeline already had a root +page.svelte — no work there.
- /broadcasts already had a root +page.svelte — no work there.
/spaces/members page chrome:
- Swapped the hand-rolled header for @mana/shared-ui PageHeader with
backHref="/", breadcrumb "Workbench › Mitglieder verwalten", and the
space name + type as the description. Feels like a native Mana page
now instead of an orphaned admin route.
- Dropped the ~60 lines of unused .type-chip CSS (moved the chip info
into the PageHeader description string).
- Container bumped to 720px max-width to match other admin pages.
0 errors across 7236 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three final pre-dogfood items:
1. drizzle.config: schemaFilter now includes 'broadcast' alongside
'mail'. Without this, `bun run db:push` skipped the broadcast
tables — schema existed in code but not in Postgres. Tested via
db:push + psql \dt (3 tables created: campaigns, events, sends).
2. .env.development: new MANA-MAIL SERVICE section with Stalwart
knobs + broadcast config (tracking secret, rate limits, send
throttle). DEV secret is explicitly labelled non-production —
prod rotates via env.
3. generate-env.mjs: new block writes services/mana-mail/.env on
`pnpm setup:env`. Mirrors the invoices / research / events
pattern. All 16 broadcast/mail vars flow through from SSOT.
Verified end-to-end:
- pnpm setup:env → services/mana-mail/.env contains
BROADCAST_TRACKING_SECRET + rate limits
- bun run src/index.ts → /health returns 200 with the new config
- psql → broadcast.campaigns / events / sends are materialised
Broadcast module is now fully ready to send real mail — nothing
else required before the first dogfood campaign.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pocket-style module for saving arbitrary web URLs, extracting readable
content server-side via @mana/shared-rss (Readability + JSDOM), and
storing it AES-GCM encrypted in IndexedDB for offline reading.
M1 skeleton: Dexie v33 (articles, articleHighlights, articleTags),
crypto registry entries, module registration, app-registry entry with
orange icon, empty-state ListView. articleTags is a pure junction
into the existing globalTags system (appId 'tags') — same pattern as
noteTags, eventTags, placeTags.
M2 URL save + reader: POST /api/v1/articles/extract (one endpoint,
not two — client caches the preview payload to avoid a double
server fetch). AddUrlForm with scope-aware dedupe, DetailView with
ReaderView typography shell (serif/sans, light/sepia/dark, size
slider), auto-tracked reading progress with scroll restore.
M3 highlights: TreeWalker-based plain-text offset resolution
(lib/offsets.ts), highlights store, floating HighlightMenu with
create + edit modes, HighlightLayer orchestrator that wraps/unwraps
highlight spans whenever highlights or htmlVersion changes. Four
colours (yellow/green/blue/pink), optional notes, click-to-edit,
dark-mode-aware overlay colours.
Drive-by: removed stale 'pendingProposals' entry from the plaintext
allowlist — the table was dropped in Dexie v29 and the allowlist
audit was flagging it as a dead entry.
Plan: docs/plans/articles-module.md. M4 (tags + filter + progress),
M5 (news:type='saved' migration), M6 (AI tools), M7 (share target),
M8 (highlights view + stats) still open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Members page had the same bug as SpaceCreateDialog before its rewrite:
var(--color-surface-1, white) and friends with hardcoded white
fallbacks meant the panels, inputs, and member rows rendered as
blazing-white boxes in dark mode, with the member name going invisible
(white-on-white) and the header chips losing contrast.
Rewrote the stylesheet to mirror @mana/shared-ui Pill conventions:
- hsl(var(--color-card|background|foreground|border|muted)) throughout
- per-type chip colors get a :global(.dark) variant with inverted
lightness so Memoro's MARKE badge reads in both themes
- panels use --color-card + border + subtle shadow (matches Pill)
- inputs use --color-input with --color-background fallback + a
primary-tinted focus ring (box-shadow color-mix)
- buttons use --color-primary with --color-primary-foreground
- member rows use --color-muted with a half-opacity border
- remove-btn hover uses color-mix so the red accent matches the theme
- error / success messages get dark-mode color pairs
Also tightened the page:
- Container padding bumped to breathe against the PillNav chrome.
- h2 "Einladen" / "Mitglieder" are small uppercase labels now — they
were invisible before and the chunky headline size competed with
the page title.
- Member row min-width + ellipsis so long names don't push the role
badge off the right edge.
0 errors across 7234 files.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switching to a non-personal space then refreshing reverted to Personal:
GET http://localhost:3001/api/auth/organization/get-active-member
→ 400 Bad Request (NO_ACTIVE_ORGANIZATION)
Dev-mode cross-origin + SameSite=Lax means the Set-Cookie from Better
Auth's /organization/set-active response is quietly dropped by the
browser. The server's session record never gets activeOrganizationId
updated, so on the next page load get-active-member throws BAD_REQUEST
with NO_ACTIVE_ORGANIZATION — and my fallback promoted Personal,
reverting the user's explicit choice.
Fix: client-side localStorage hint that mirrors the server preference.
loadActiveSpace():
1. Ask the server (get-active-member) — trust it if it knows the org.
2. Otherwise fetch the org list + read the hint from localStorage.
If the hint matches an org the user is actually a member of, call
set-active again to re-sync the server and use that org.
3. Only fall back to Personal when there's no hint (truly fresh
session or first-login).
writeActiveSpaceHint() is called from:
- SpaceSwitcher.switchTo() — on explicit switch.
- SpaceCreateDialog — on successful create (the user just chose it).
- accept-invitation — on accept (user opted in to the new space).
- loadActiveSpace() itself — after any successful resolve, so the
hint stays current.
Exported from $lib/data/scope so additional flows (admin tools,
future delete-space) can keep it in sync.
Production impact: once COOKIE_DOMAIN=.mana.how is set with
SameSite=None+Secure, the server cookie takes precedence and the
hint is redundant but harmless.
0 errors across 7234 files.
Plan: docs/plans/spaces-foundation.md
SSR 500'd on every (app)/* route with:
TypeError: Cannot read properties of undefined (reading 'table')
at apps/mana/apps/web/src/lib/modules/meditate/collections.ts:13:39
at async eval (.../meditate/stores/meditate.svelte.ts:...)
Root cause: ai/missions/setup.ts had static side-effect imports of
meditate/habits/goals seed modules. Each seed module transitively
imports its module's collections.ts, which does `db.table(...)` at
module-eval time. During SSR, Vite's module-runner evaluates imports
depth-first — the seed imports race database.ts's own eager
dependency eval, observe `db` as still-undefined (live-binding in
the middle of a circular chain), and crash.
Fix: replace the three `import '$lib/modules/<X>/seed'` side-effect
imports with a single async `ensureSeedsRegistered()` that dynamic-
imports them, guarded by the `browser` flag so SSR never touches
them. Called fire-and-forget from `startMissionTick` (which is
itself client-only via the onMount wrapping in +layout.svelte), so
template applicators still see the registry populated before they
need it.
Net effect:
- SSR chain for any (app)/* route no longer touches meditate/habits/
goals collections.ts → no db-undefined race.
- Browser behavior unchanged: seeds register at the first mission
tick, just like before, before any template applier runs.
Verified: after HMR/manual cache-bust, curl / returns 200 in place
of the previous 500. Type-check 0 errors across 7230 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last plan milestone. Users can verify their sending-domain
setup without leaving the broadcast settings page.
Server (mana-mail)
- services/dns-check.ts: parseSpf / parseDkim / parseDmarc are pure
functions. SPF accepts include:<mailDomain>, flags weak (+all) and
wrong (include missing) and multi-record (RFC 7208 §3.2). DKIM
needs v=DKIM1 + a p= public-key segment. DMARC requires v=DMARC1,
flags p=none as weak (monitoring only), ok on quarantine/reject.
All three are case-insensitive.
- lookupTxt(): DNS-over-HTTPS against Cloudflare 1.1.1.1 — avoids
the Bun/container udp-resolver flakiness and works everywhere.
Multi-string TXT (`"a" "b"`) get concatenated before parsing.
- checkDomain(): one call, three parallel DoH lookups, returns a
structured result with suggested copy-paste records scoped to the
user's actual mail domain from config.
- Route: GET /v1/mail/dns-check?domain=&selector= (JWT auth). Zod
validates the domain looks sensible before hitting DoH.
- 16 unit tests covering all three parsers + multi-record edge case.
Client
- api.ts: runDnsCheck(domain, selector?) helper with typed result.
- components/DnsCheckBanner.svelte: derives domain from the default
from-email (after @), calls the check on-demand, renders per-record
status chips (ok / weak / wrong / missing) with messages, exposes
copy-pasteable SPF + DMARC records when anything's off. DKIM setup
is provider-specific so we show a hint rather than a canned record.
Last-check timestamp persists to settings.dnsCheck so the banner
survives a reload without re-hitting the API.
- Wired into SettingsForm between Impressum and Standard-Footer —
where the user is already thinking about "what's required to
actually send".
All checks clean:
- webapp pnpm check: 0 broadcast errors (4 pre-existing articles errors
from parallel Spaces work, unrelated)
- mana-mail tests: 36/36 across tracking-token + link-rewriter + dns-check
- mana-mail build: 2.51 MB (+8 KB for juice — dns-check itself is ~3 KB)
Plan: docs/plans/broadcast-module.md §M8. All 10 milestones now done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the M7/M9/M10 plan items in one pass since they share patterns.
ListView (M7)
- 4 stats cards at the top: versendet YTD, Ø Öffnungsrate, Ø Klickrate,
Entwürfe. Same layout pattern as invoices for consistency.
- Status filter chips with live counts per status.
- Search across name + subject.
- Row now shows open-rate per-campaign when available.
- Settings gear in the header matches the invoices polish.
Dashboard widget (M10)
- BroadcastsWidget.svelte: 2x stats (sent YTD + avg open rate), next
scheduled link, last sent link with open-rate badge. Empty state
nudges toward creating a first campaign.
- Registered as 'broadcasts' in WIDGET_REGISTRY and the component map.
- Medium default size, no requiredBackend (reads from Dexie only;
stats are mirrored from the last DetailView poll so no server
round-trip for the widget).
AI tools (M9)
- 3 tools added to @mana/shared-ai's AI_TOOL_CATALOG:
- create_campaign_draft (propose) — generates HTML body from a
topic, lands as a draft; user picks audience + sends via UI
- list_campaigns (auto) — id/name/subject/status/recipients
- get_campaign_stats (auto) — rates as 0..1 floats
- broadcast/tools.ts: execute handlers with an HTML→CampaignContent
shim (stores both html and a minimal Tiptap JSON placeholder so
ListView renders without the editor having to remount). stripHtml
helper derives plaintext.
- Registered in data/tools/init.ts after library.
Suggest-style tools (suggest_subject_lines) deliberately omitted —
they're pure generative and don't need an executor. The LLM can
produce subject ideas without a tool call.
Verified:
- pnpm check: 0 broadcast errors (4 pre-existing errors in articles
module from parallel work, not mine)
- shared-ai test suite: 44/44 green (function-schema roundtrips the
expanded catalog cleanly)
- mana-ai drift guard: 41/41 green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two visible bugs on the Neuer-Space dialog in dark mode:
1. Type cards were invisible except for the active one. The CSS used
`var(--color-border, hsl(0 0% 88%))` — a CSS variable without HSL
wrapping. The theme system actually uses shadcn-style raw values
(e.g. `--color-border: 0 0% 88%`) that need to be wrapped with
`hsl(...)` at use-site. Without the wrap the border was undefined,
and the card fell back to "no border and no background" = loose
text on the dialog.
2. Input fields (Name, URL-Kürzel, Brand-Voice) rendered with a
hard-coded white fallback because `var(--color-surface-1, white)`
ignored the theme token in dark mode. Same wrapping issue as #1.
Rewrote the dialog stylesheet to use the `hsl(var(--color-X))` pattern
consistently — mirroring @mana/shared-ui Pill.svelte, which does work
in both modes. Adjacent polish:
- Every type card now has border + subtle background in all states;
hover lifts it; active uses color-mix with --pill-primary-color so
it picks up the current theme variant's accent.
- Input fields use --color-input (shadcn standard) with --color-background
fallback, with a primary-tinted focus ring.
- Section labels (legend, field > span) styled as small uppercase
labels matching other form conventions.
- Backdrop is a bit darker and gets an extra alpha bump in dark mode.
- Error panel uses color-mix so it blends into dark mode instead of
showing a bright light-red panel.
- Buttons use --color-primary with --color-primary-foreground for the
text, with a small brightness filter on hover.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last two dogfood blockers before real-campaign use.
link-rewriter.ts
- rewriteClickLinks(): walks <a href="http…"> in the HTML body and
replaces each URL with /api/v1/track/click/{token}?url={original}
so clicks go through the tracking endpoint. Regex-based because
Tiptap output is well-formed; returns a count for debugging.
- Leaves mailto: / tel: / anchor fragments alone — wrapping those
breaks the recipient's native handler and accomplishes nothing.
- `skipUrls` param carries the unsubscribe + web-view URLs (already
tracking endpoints themselves) so they don't get double-wrapped.
- 11 unit tests covering http/https rewriting, skip list, non-http
schemes, attribute preservation, multi-link count, quoted-attr
variants, idempotency.
Orchestrator wiring
- substituteUrls now calls rewriteClickLinks after the preview-
placeholder swap and before the open-pixel injection. The
unsubscribe + web-view URLs from this same function are passed
in as skip entries so they survive the pass untouched.
- Constructor gains `sendThrottleMs` param (default 150ms).
- Main send loop awaits sleep(throttleMs) between iterations. 150ms
= ~6/sec = ~360/min, safely below most SMTP provider limits.
100-recipient campaign = ~15s extra wall-clock but that's fine
for MVP (and most campaigns are way smaller).
Config
- New env BROADCAST_SEND_THROTTLE_MS (default 150). Wired from
loadConfig to the orchestrator constructor.
The broadcast module is now functionally complete for dogfooding.
Remaining before a real campaign can actually go out: run
`cd services/mana-mail && bun run db:push` to materialise the
broadcast.* schema tables.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the "could actually dogfood" gap: legal address can be set,
sent campaigns have a proper view with live stats, and the send path
respects DSGVO.
Webapp
- components/SettingsForm.svelte: sender defaults + Impressum (required,
highlighted amber until filled) + footer. Matches the invoices
SenderProfileForm pattern — immediate save, dedicated section per
concern.
- /broadcasts/settings/+page.svelte: mounts the form. ComposeView step
3's "Einstellungen öffnen" CTA now lands somewhere.
- views/DetailView.svelte: read-only view for sent/scheduled/cancelled
campaigns. 5-card stats grid (sent, open, click, bounce, unsub) with
rate percentages. Polls mana-mail every 30s for up to 30 min after
mount, persists back to Dexie via applyServerStatus so the list view
+ widget catch up. Includes a preview of the actual rendered campaign
so "what went out" is visible after the fact.
- /broadcasts/[id]/+page.svelte: DetailView for non-drafts; drafts
bounce to /edit via $effect-triggered goto.
- ListView row-click now routes by status (draft → edit, else → detail).
mana-mail compliance
- Orchestrator loadUnsubscribedEmails(): queries broadcast.sends WHERE
status='unsubscribed' scoped to the user, filters the recipient list
BEFORE any send rows get written. Campaign's totalRecipients reflects
the post-skip count so open rates aren't inflated by "virtual sends".
Skipped count surfaces in result.errors for the UI to show.
- jmap-client.submitEmail: new extraHeaders param. Sets custom headers
via JMAP's `header:<Name>:asText` property convention.
- Orchestrator sets RFC 8058 headers per recipient:
List-Unsubscribe: <https://.../track/unsubscribe/{token}>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
This is what makes Gmail / Apple Mail show their native "Abmelden"
button in the message header (not just a body link).
All checks clean: 0 TS errors, 37/37 webapp tests, 9/9 tracking-token
tests, mana-mail bun build = 2.50 MB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Opening the Space-Switcher threw "process is not defined" because the
missing-injection fallback inside the browser branch returned early
only when the injected URL was truthy, then fell through to a
`process.env.PUBLIC_MANA_AUTH_URL` read. `process` doesn't exist in
the browser, so empty injection = ReferenceError.
Fix: the browser branch now always returns (injected or localhost:3001
fallback) without touching `process`. The env-var read stays inside
the SSR path and is further guarded by `typeof process !== 'undefined'`
so a future change can't regress.
0 errors across 7203 files.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end send path lives: click "Jetzt senden" in step 4 → client
resolves recipients → POST /v1/mail/bulk-send → mana-mail loops through
JMAP with per-recipient signed URLs → status flips draft → sent.
mana-mail (backend)
- New Postgres schema `broadcast.{campaigns,sends,events}` in Drizzle.
Campaigns + sends keyed on the webapp's local ids so joins are free;
events append-only with send_id FK, dedup at query-time not write-time
so tracking pixel hits don't contend on a transaction.
- tracking-token.ts: HMAC-SHA256 over JSON({campaignId, sendId, nonce}),
base64url.base64url encoded. JSON inner payload instead of delimiter
splits so IDs can contain any character. timingSafeEqual for the HMAC
comparison. 9 unit tests covering roundtrip / tamper / malformed.
- broadcast-orchestrator.ts: takes pre-resolved recipient list, inlines
CSS once via juice (webResources.images=false so no external fetches
slow the loop), per-recipient substitutes `{{unsubscribe_url}}` /
`{{web_view_url}}` + injects open pixel, submits each mail through
the user's own JMAP account. Writes sends rows first (status=queued)
so a crash mid-loop leaves truthful DB state. Returns aggregate
stats + per-email errors.
- Routes: POST /v1/mail/bulk-send (JWT, cap at 5000 recipients via
zod + config), GET /v1/mail/campaigns/:id/events (JWT, aggregates
opens + clicks + unsubscribes with COUNT DISTINCT for the "unique"
metric), GET/POST /v1/track/{open,click,unsubscribe}/:token (public,
no auth, signed URL is the only gate).
- Track routes mounted OUTSIDE /api/v1/mail/* because the JWT
middleware guards that subtree — recipients aren't logged in.
- Config: BROADCAST_TRACKING_SECRET (separate from SERVICE_KEY so the
blast radius of a leak stays narrow),
BROADCAST_MAX_RECIPIENTS_PER_CAMPAIGN (default 5000),
BROADCAST_MAX_RECIPIENTS_PER_HOUR (default 500, not yet enforced).
- Added juice@^11 dependency.
Webapp (client)
- api.ts: sendCampaign() resolves the audience from Dexie contacts,
renders the full email HTML + plaintext with placeholders, POSTs to
mana-mail. Contacts NEVER leave the client decrypted — the server
only sees the flat recipient list the user's client produced.
- fetchCampaignStats() for M7 dashboard/detail polling.
- ComposeView step 4 replaced: confirmation modal with "sicher?"
question, sending state with spinner, done state with delivered
count + expandable per-email error list + "Zur Übersicht" button.
- Status transitions to 'sent' with cached stats after successful
send via applyServerStatus.
Known M4 gaps (fill in M5)
- Open/click/unsubscribe track endpoints return valid responses but
event dedup is rough — one insert per hit, dedup at query time
only. M5 adds windowed IP-hash dedup.
- Synchronous send loop. 100 recipients ≈ 15s blocking. M5/M6 moves
this to an async job queue with SSE progress.
- Each recipient generates a "Sent" folder entry in the user's
Stalwart mailbox. Fine for 50-recipient newsletters, silly for
5000. Phase 2 carves out a dedicated broadcast mailbox.
Plan: docs/plans/broadcast-module.md §M4.
Next: M5 open/click tracking with dedup + rate-limits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All the Space-UI fetches were using relative `/api/auth/...` paths,
which hit the SvelteKit dev server (port 5173) — where those routes
don't exist — and 404'd. The web app has no `/api/auth` proxy; every
Better Auth call must go direct to mana-auth (port 3001 in dev).
Root cause parallels how packages/shared-auth/authService already
works: it builds `${authBaseUrl}/api/auth/...` against
window.__PUBLIC_MANA_AUTH_URL__ or the env fallback.
Fix:
- New helper $lib/data/scope/auth-fetch.ts exposes authFetch(path, init)
that prepends the auth base URL and includes credentials by default.
Same resolution order as shared-auth's authService (injected global,
env, localhost:3001 fallback).
- Updated every organization-endpoint caller to use authFetch:
active-space.svelte.ts (list, get-active-member, set-active)
SpaceSwitcher (list, set-active)
SpaceCreateDialog (create, set-active)
accept-invitation page (get-invitation, accept, reject)
/spaces/members page (list-members, list-invitations, invite-member,
cancel-invitation, remove-member)
- active-space now treats Better Auth's 400 as "no active org" too
(not just 404) so the bootstrap falls through to auto-activation.
Trusted origins already include http://localhost:5173 — no CORS change.
0 errors across 7203 files.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library module had no AI tool coverage post the M1 skeleton. Adds
four tools so the agent can curate the reading/watch list alongside
other modules:
- create_library_entry (propose) — books/movies/series/comics with
creators, year, status, rating, tags, genres. Default status
"planned" covers the most common flow ("add to watchlist").
- update_library_entry_status (propose) — status transitions
planned → active → completed (also paused / dropped). Auto-
stamps startedAt/completedAt on the matching transitions so the
existing Dexie projections (streaks, progress) fire correctly.
- rate_library_entry (propose) — 1-5 stars, thin wrapper over the
store's rate() method.
- list_library_entries (auto) — id/kind/title/status/rating/year,
filterable by kind + status.
Coverage table in apps/mana/CLAUDE.md updated (+library, +invoices
row that wasn't listed). Total now 67 tools / 21 modules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two problems made the switcher unusable inside the PillNav:
1. Menu was getting clipped — .pill-nav-container has overflow-x: auto,
which hides any position: absolute child that extends past the bar.
Switched to position: fixed with getBoundingClientRect coordinates
(same pattern @mana/shared-ui PillDropdown uses). Menu now escapes
the bar container cleanly and opens upward on the viewport.
2. Trigger and menu didn't match Pill design tokens. Rewrote the
styles to mirror Pill.svelte: pill-shaped 36px height, box-shadow,
hsl(var(--color-card)) background, hsl(var(--color-border)) border,
active-state color-mix with --pill-primary-color, dark-mode variant.
Other polish:
- Replaced per-type colored backgrounds with a small type-dot + a
proper type-label chip inside each menu row. Matches the tone of the
type chips used elsewhere, and the chip adapts to dark mode.
- Full-viewport backdrop button captures click-outside at z=1500.
- Menu z=1501, create dialog z=1601 so the stack is well-ordered
(PillNav=1000, menu=1501, dialog=1601).
- Chevron rotates on open (matches other PillDropdown affordances).
- Resize/scroll listeners reposition the menu while it's open so the
anchoring survives layout changes.
- SpaceCreateDialog's backdrop + dialog z-index bumped from 200/201
to 1600/1601 so it sits above the menu that spawned it.
0 errors across 7201 files.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The timeline is now the primary AI-review surface post function-
calling migration — a handful of ergonomics tweaks so scanning a
day's AI activity is less friction.
- Time-range toggle (24h / 7T / alle) right-aligned in the filter
row. Default stays `alle` so nothing changes for users who want
everything. Client-side filter — over-fetch already caps at 500.
- Each bucket shows an event-count pill next to the mission title
("8 Änderungen in dieser Iteration"), so the reader sees the
weight of an iteration before expanding.
- Revert button: slightly bigger, label reads "Rückgängig" instead
of "Revert" (matches the rest of the German UI), bold icon, hover
highlights with a softer red tuned to the theme-token palette.
No logic changes to the revert or bucketing code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Repositions the switcher from its floating spot in the top right of
the workbench into the bottom-fixed PillNav so it sits with the rest
of the nav chrome. Matches how every other persistent nav control
(app switcher, AI tier, sync status) lives in the PillNav.
Mechanics:
- @mana/shared-ui PillNavigation gains a `startSlot?: Snippet` prop
rendered inside .pill-nav-container, before AppDrawer. Generic slot
— any host component drops in.
- (app)/+layout.svelte passes the existing <SpaceSwitcher /> as the
snippet (authenticated only). The old .space-bar wrapper above
<main> is removed along with its CSS.
- SpaceSwitcher trigger is restyled to match Pill conventions: pill
radius 999px, 32px height, 0.8125rem text, tighter paddings, shorter
name cap (7rem). Visually merges with the surrounding Pills.
- Dropdown menu flips upward (bottom: calc(100% + 4px)) because the
PillNav is position:fixed bottom — opening downward would land
off-screen.
Type-check: 0 errors across 7200 files.
Scope tests: 10/10 pass.
Go tests + bun tests (mana-auth): all pass.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new counters + one histogram fill the observability gap from
the function-calling migration:
- mana_ai_tool_calls_total{tool, policy, outcome} — one tick per
tool_call the planner produced. `outcome` is `deferred` on the
server (stub onToolCall records for later client execution);
webapp runner will emit success/failure once it grows its own
Prom surface.
- mana_ai_planner_rounds (histogram, buckets 1..5) — distribution of
rounds consumed per iteration. Runs close to the cap signal a
planner struggling with the mission objective.
- mana_ai_provider_errors_total{provider, kind} — structured errors
surfaced from mana-llm. Kind mirrors the ProviderError hierarchy
added in commit 1 of the migration (blocked/truncated/auth/
rate_limit/capability/unknown).
Plumbing:
- llm-client.ts parses mana-llm's `{detail: {kind, message}}` 4xx/5xx
body shape and re-throws as ProviderCallError carrying the kind.
- tick.ts observes metrics at the natural emission points — rounds
+ per-call counter after runPlannerLoop returns, provider_errors
in the catch block.
Grafana dashboards + status.mana.how already pick up the
collectDefaultMetrics prefix, so these metrics land in the existing
mana-ai panel without scraper changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 3 of the compose wizard (Preflight) now shows a real preview +
pre-send checks. User sees exactly what the recipient gets, minus the
substituted tracking URLs.
Render (render/email-html.ts)
- renderEmailHtml(): wraps Tiptap HTML in an email-client-compatible
shell — inline style="" throughout, single-cell table layout,
preheader block (hidden from body, visible in inbox preview), sender
banner, footer with unsubscribe + legal address
- HTML-escapes subject / sender name / address (Tiptap body stays
verbatim since its schema already forbids <script> / on* handlers)
- Placeholders for {{unsubscribe}} / {{web_view}} URLs — mana-mail's
orchestrator substitutes the HMAC-signed per-recipient URL at send
time; preview uses #unsubscribe-preview so nothing 404s
Render (render/plain-text.ts)
- renderPlainText(): mandatory multipart/alternative counterpart that
keeps SpamAssassin happy. Soft-wraps at 72 chars (honours the old
78-char RFC limit with a bit of quote-prefix headroom). Same footer
signals as HTML so the "why am I getting this" info travels with
text-only clients too.
Preview (preview/)
- EmailPreview.svelte: iframe with srcdoc (no blob-URL revocation
overhead) + macOS-style device chrome, width 640/375 for desktop/
mobile. sandbox="allow-same-origin" only — no top navigation, no
script execution.
- PreviewTabs.svelte: Desktop / Mobile / Nur-Text switcher, derives
html + plaintext reactively so edits in step 2 reflect immediately
when the user hops back to step 3.
Preflight checks (ComposeView step 3)
- Subject set? Audience > 0? Sender valid? Legal address filled?
- Legal-address warning is actionable — links straight to
/broadcasts/settings. Unblocks the user instead of just scolding.
Tests (20 new, 57 total for broadcast)
- email-html: subject in title, XSS-escape, preheader hidden, body
verbatim, empty-body fallback, sender name, unsubscribe default vs
override, legal address line breaks, optional footer, web-view link
- plain-text: sender, body, 72-char wrapping, paragraph preservation,
unsubscribe placeholder vs override, legal at end, optional footer
Still client-side only; server-side juice inlining lands in M4 when we
add richer CSS (currently everything inline already, so juice just
passes through).
Plan: docs/plans/broadcast-module.md §M3.
Next: M4 bulk-send via mana-mail (synchronous loop, HMAC tokens).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the gap between "invite flow UI exists" and "two users in the
same space actually see each other's data". Three pieces land together
because they're meaningless without each other.
mana-auth — new internal endpoint:
GET /api/v1/internal/users/:userId/memberships
Returns [{organizationId, role}, ...] for the user. mana-sync uses
this to populate the multi-member RLS session config.
mana-sync — membership lookup:
new internal/memberships package with an HTTP client + 5 min
per-user cache, fail-open (empty list = pre-Spaces behavior).
Config gets MANA_AUTH_URL (default http://localhost:3001).
Handler.NewHandler takes the Lookup. Every Push/Pull/Stream call
now passes spaceIDsFor(userID) to Store methods.
GetChangesSince + GetAllChangesSince extend their WHERE clause:
WHERE (user_id = $1 OR space_id = ANY($memberSpaces))
so co-members see each other's rows, not just the author.
apps/web — encryption skip for shared-space records:
encryptRecord now checks record.spaceId:
- `_personal:<userId>` sentinel OR no active shared space → encrypt
with user master key (E2E as today).
- Active space resolves to non-personal type AND spaceId matches
that space → skip encryption; write lands plaintext.
decryptRecord is unchanged because its per-field isEncrypted() guard
already passes plaintext through.
Phase-1 compromise: shared-space data is protected by server RLS
only, not E2E. Phase 2 adds per-Space shared keys with per-member
wrap — tracked in docs/plans/spaces-foundation.md.
Plus docs/plans/shared-space-smoketest.md: step-by-step Zwei-User-Test
mit erwarteten Ergebnissen und Debugging-Hinweisen bei Problemen.
Build + go test + web check all green.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the macmini compose — Google Gemini was already wired in the
provider adapter (commit 2 of the function-calling migration) but the
local dev stack's compose never passed the env through, so the
container booted without the provider and every tool-calling request
fell back to Ollama (unreachable in local dev, LAN-only GPU box).
With this in place the local mana-llm healthcheck reports both
`google` and `openrouter` as healthy and the webapp planner hits
Gemini Flash for real.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Core authoring loop works end-to-end: create a draft, filter an audience
from contacts, write content in a rich-text editor, save. Send is still
stubbed (M4 gets mana-mail's bulk endpoint).
Dependencies
- @tiptap/core + starter-kit + image + link + placeholder (3.22.4)
- shared-auth/tsconfig: allowImportingTsExtensions +
rewriteRelativeImportExtensions so tsc accepts shared-types' explicit
.ts imports. Was blocking EVERY pnpm install postinstall hook in the
repo — fixing it here unblocks everyone, not just broadcast.
Module
- queries.ts: useAllCampaigns / useAllTemplates with scoped-db + crypto,
computeStats (counts + open/click rates per year), formatRate helper
- stores/settings.svelte.ts: singleton with ensure/get/update, same
pattern as invoices settings
- stores/campaigns.svelte.ts: createCampaign (pulls sender defaults from
settings), updateCampaign / updateContent / updateAudience (draft-only
edit guard), schedule / cancel / duplicate / deleteCampaign, plus an
applyServerStatus hook for M4's orchestrator to write back progress
Audience
- audience/segment-builder.ts: pure matchContact / filterAudience /
countAudience / describeAudience. AND semantics across filters. Drops
contacts without a usable email so estimatedCount never inflates.
- audience/AudienceBuilder.svelte: tag-chip UI with live count, dedup
(same tag twice toggles op instead of stacking), greys out already-
referenced tags in the picker
Editor
- editor/Editor.svelte: Tiptap wrapper with onMount / onDestroy, toolbar
(bold/italic/H1/H2/lists/link/image), bind on content (Tiptap JSON +
derived HTML/plaintext). Image upload reuses invoices' mana-media
uploader pragmatically; extract to @mana/shared-uload later.
Compose wizard
- views/ComposeView.svelte: 4-step stepper (Audience → Content →
Preflight → Send). Steps 3+4 stubbed pragmatically. Autosave on step
change so content survives navigation. Step 3/4 gated on earlier
readiness so the user can't skip.
Routes
- /broadcasts/new: bootstraps a draft + redirects to edit
- /broadcasts/[id]/edit: guarded on status=='draft'
- ListView: working "+ Neue Kampagne" button, rows open edit
Tests
- 17 unit tests for segment-builder covering tag has/not-has/AND,
email eq/contains case-insensitivity, no-email filtering, no-mutation,
describeAudience resolver + fallback
Plan: docs/plans/broadcast-module.md §M2.
Next: M3 HTML-render with email-safe inlining + preview.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First user-facing surface for multi-tenant Space sharing. Two new routes:
/spaces/members — Space member management (inside app gate)
- Lists current members with role chips + avatars.
- Einladen-Form for owners/admins: email + role (member/admin) →
POST /api/auth/organization/invite-member. Better Auth's existing
sendInvitationEmail handler (wired in better-auth.config.ts) mails
the invitee a link to /accept-invitation?id=X.
- Pending-invitations list with Stornieren button.
- Personal Spaces show a hint panel instead — they can't have members
by design.
- Remove Mitglied button (not for owner-role).
/accept-invitation — landing page for the invite email link (outside
(app) guard so logged-out invitees can see it).
- Fetches invitation details via /organization/get-invitation.
- If unauthenticated: "Einloggen & annehmen" routes through /login
with a callbackURL back to the landing — the flow resumes after
sign-in.
- Accept: POST /organization/accept-invitation + /set-active so the
newly-joined space is active when the user lands in the app.
- Decline: POST /organization/reject-invitation.
- Already-accepted / expired / canceled states each get their own copy.
SpaceSwitcher gets a "Mitglieder verwalten …" entry in the dropdown,
visible only when the active Space isn't personal.
What this does NOT do yet (separate commits):
- Membership-Lookup in mana-sync — Users A and B can now be in the
same space on paper, but mana-sync's RLS only lets members see
their own authored records until the lookup is wired.
- Encryption skip for shared-space rows — records in an encrypted
table still get wrapped with the author's user key, so member B
can't decrypt member A's writes.
Both follow in the next two commits.
0 errors across 7194 files.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>