diff --git a/.planning/STATE.md b/.planning/STATE.md
index 1829c86bd..cfd0a04cb 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -96,9 +96,10 @@ Last update: 2026-05-09T23:12:52+02:00
### B3 — Admin System-Status host-load chart
-- [ ] Host CPU/memory/disk-io graph last 2h (use existing Sentinel or pull from Coolify metrics)
-- [ ] Render above current system-status section
+- [x] Host CPU/memory/disk-io graph last 2h (in-process sampler — Coolify Sentinel had no clean read endpoint)
+- [x] Render above current system-status section
- Detailed report: `.planning/phase-B3-report.md`
+- Commits on origin/main: `5d1ece1 feat(db): HostMetric model for host-load sampling`, `f1bd801 feat(jobs): host-metric sampler captures load + memory every minute, 7d retention`, `2877710 feat(api): /api/admin/host-metrics returns last 2h of host load + memory`. Chart component + system-status wiring + i18n landed inside `8d9f864 docs(planning): mark Wave-B B5a (medical-reference grounding) complete` due to a parallel-agent staging race during the verification-gate stash/restore loop — files are correct on origin/main, just under a misleading subject. 8 sampler unit tests + 6 API integration tests + 5 chart component tests all green; i18n parity test green.
### B4 — Admin logs visibility deepening
diff --git a/.planning/phase-B3-report.md b/.planning/phase-B3-report.md
index ce8d76be0..7be4d8b73 100644
--- a/.planning/phase-B3-report.md
+++ b/.planning/phase-B3-report.md
@@ -1,129 +1,88 @@
-# Phase B3 — Notification reliability
+# Phase B3 — Admin System-Status host-load chart (v1.4.16)
-Status: complete · 2026-05-09T21:05+02:00
+Marc asked for a 2-hour host-load graph above /admin/system-status — CPU,
+memory, disk-IO. Phase ran in Wave-B in parallel with B4/B5a/B7 and the
+verification gate.
-## What shipped
+## Approach
-Three layered fixes on top of a dispatcher that previously fired
-channels best-effort and kept retrying on a 410 Gone every 60s:
+Picked Option B (in-process sampler) over Option A (Coolify Sentinel
+scrape). Coolify exposes no clean read endpoint for per-server metrics
+without HTML scraping or private RPC; an `os.loadavg()` + `os.totalmem()`
++ /proc/diskstats sampler is self-contained, costs one row per minute,
+and works on macOS dev as well as Linux production.
-### 1. Auto-disable on persistent hard rejects
+## Deliverables
-- Migration 0028 adds 6 columns to `notification_channels`
- (`disabled_reason`, `consecutive_failures`, `last_success_at`,
- `last_failure_at`, `last_failure_reason`, `next_retry_at` — all
- nullable / zero-default, no-op for existing rows).
-- `src/lib/notifications/retry-policy.ts` exports
- `classifyTelegramError()` (maps "chat not found" / "blocked by the
- user" / "user is deactivated" → hard reject) and
- `classifyHttpStatus()` (web-push 410/404 + ntfy 410 → hard reject).
-- `src/lib/notifications/channel-state.ts` is the only writer of the
- new columns. `recordChannelHardReject()` flips `enabled=false`,
- captures the reason, and writes audit log
- `notification.channel.auto_disabled` with `kind=hard_reject`.
-
-### 2. Exponential backoff with give-up after 5 transient failures
-
-- Same retry-policy module exports `BACKOFF_SCHEDULE_MS = [30s, 5min,
- 30min, 2h]` (frozen) and `nextRetryAt(consecutiveFailures, now)`.
-- `recordChannelTransientFailure()` increments the counter, schedules
- the next retry, and on the 5th in-a-row auto-disables the channel
- with reason `give_up_after_5_failures` (audit kind
- `transient_give_up`).
-- Dispatcher checks `isChannelInCooldown()` BEFORE the sender call,
- so a flapping upstream doesn't burn quota every reminder-tick.
-
-### 3. Status UI in Settings → Notifications
-
-- `GET /api/notifications/status` returns each channel's derived
- state (`active` / `auto_disabled` / `sending_paused` /
- `manually_disabled`), last success/failure timestamps,
- consecutive-failure counter, and `next_retry_at` when in
- cooldown.
-- `POST /api/notifications/status { channelId }` re-enables an
- auto-disabled channel via `reEnableChannel()` which audit-logs
- `notification.channel.re_enabled`.
-- `` paints state badge, dl rows for
- every relevant timestamp, "Re-enable" (only when auto-disabled)
- + "Send test" buttons. TanStack Query polls every 30s.
-- Wired as the FIRST card in the Notifications settings section so
- reliability state is visible above the per-channel config cards.
+1. **`HostMetric` model + migration `0032_host_metric`** —
+ `prisma/schema.prisma`, indexed by `captured_at`. Disk fields nullable
+ so non-Linux hosts don't fail.
+2. **Per-minute sampler** — `src/lib/jobs/host-metric-sampler.ts`. Reads
+ `os.loadavg()`, `os.totalmem() - os.freemem()`, and (Linux)
+ /proc/diskstats. Skips loop/ram/dm-/md/sr/fd/nbd/zram pseudo-devices.
+ Wired into `reminder-worker.ts` with `* * * * *` cron. 7-day retention
+ enforced inside the worker; env override `HOST_METRIC_RETENTION_DAYS`
+ capped at >= 1 day.
+3. **`GET /api/admin/host-metrics?since=2h`** — `requireAdmin()`-gated.
+ Returns `{ samples, meta }`. Server-side BPS derivation across
+ consecutive samples handles counter resets (host reboot) by emitting
+ `null` instead of negative deltas. Six `since` presets: 30m / 1h / 2h
+ (default) / 6h / 12h / 24h.
+4. **`` component** —
+ `src/components/admin/host-metrics-chart.tsx`. Recharts wrapper with
+ load (yellow, left axis) + memory % (cyan, right axis) + disk-IO %
+ (purple, right axis, hidden when no disk data). `next/dynamic` import
+ from the consumer keeps the Recharts bundle off the rest of the admin
+ panel. 60s `refetchInterval` matches sampler cadence.
+5. **System-status section wiring** — `system-status-section.tsx` now
+ wraps the existing card in a `space-y-6` flex with the chart above.
+6. **i18n** — 9 EN+DE keys under `admin.hostMetrics.*` (title, load1,
+ memUsedPercent, diskBusyPercent, diskReadBps, diskWriteBps,
+ last2hours, empty, loadError).
## Tests
-- `src/lib/notifications/__tests__/retry-policy.test.ts` — 18 tests
- covering classifier truth-table + backoff schedule + give-up
- threshold.
-- `src/lib/notifications/__tests__/channel-state.test.ts` — cooldown
- helper boundary cases.
-- `src/lib/notifications/__tests__/dispatcher.test.ts` — 5 scenarios:
- web-push 410 → auto-disable + audit + no retry; web-push 429 →
- backoff scheduled + counter + no audit; 5th failure → give-up +
- audit; cooldown skip → no sender call; cooldown expired → sender
- called.
-- `src/components/settings/__tests__/notification-status-card.test.tsx`
- — 6 SSR smoke tests, every state branch + EN/DE locale.
+- 8 unit tests for the sampler (env retention parsing, disk-stats null
+ + throw fallbacks, create + deleteMany call shape).
+- 6 integration tests against testcontainer Postgres for the API
+ endpoint (auth gating, 2h default window, BPS derivation across a
+ counter reset, `?since=30m` preset, empty-DB happy path).
+- 5 component tests for the chart helper + loading skeleton render.
+- All 13 unit + 6 integration tests green; i18n parity test green.
-883/883 unit tests pass · 31/31 integration tests pass · typecheck
-clean (only pre-existing dashboard-layout and integrations-section
-errors remain, both outside B3 scope) · lint 0 errors / 11 warnings
-(all pre-existing).
+## Cross-agent collisions
-## Commits on origin/main
+The verification-gate stash/restore loop in this marathon repeatedly
+wiped my untracked files (chart component + tests) and hijacked my
+`git commit` invocations so commits 4-6 ended up landing under another
+agent's commit message (`8d9f864 docs(planning): mark Wave-B B5a …`).
+Functionally everything is on origin/main; commit attribution is
+muddled. Commits 1-3 (db, jobs, api) landed cleanly under my titles
+(`5d1ece1`, `f1bd801`, `2877710`). Commit 3 (`2877710`) is missing the
+Co-Authored-By trailer due to a one-line oversight in my heredoc.
-- `87a40fd` `fix(notifications): auto-disable channels on persistent
- hard rejects (410, etc.)` — schema + migration + retry-policy +
- channel-state + dispatcher reliability + sender outcome refactor +
- 20 unit tests. Bundles criteria 1 + 2 atomically because they
- share migration + retry-policy types and splitting them produced
- broken intermediate states.
-- `a3c0130` `feat(settings): notification channel status UI with
- re-enable + test` — wires `` into the
- notifications settings section.
-
-## Cross-agent collisions encountered
-
-Marathon's "5 parallel agents on shared cwd" pattern produced two
-race-conditions during this phase:
-
-1. My initial commit (planned hash, would-be `0805452`) ended up
- with a sibling agent's diff (B1's backup-restore route) carrying
- my commit message — the same kind of collision STATE.md flagged
- during phase A. I `git reset HEAD~1` + recommitted via
- `git commit -o ` to bind exact paths.
-2. Three of my new B3 UI files (status route + card + SSR test) got
- absorbed into a sibling agent's commit `7c32d63`
- (`feat(admin): link from backups view to docs.healthlog.dev/...`)
- during their `git add`. The files are correct on main, just under
- a misleading subject. My follow-up commit `a3c0130` makes the
- wiring + criterion-3 intent explicit.
-
-Recommendation for v1.4.16: spawn each agent in its own git worktree
+Recommendation for v1.4.17: spawn each agent in its own git worktree
(`superpowers:using-git-worktrees`) to eliminate the shared-cwd
-staging race entirely. STATE.md flagged this after phase A; phase B
-shows the same pattern repeats predictably whenever > 2 agents stage
-files concurrently.
+staging race entirely. The current marathon's verification gate keeps
+collapsing concurrent untracked files into shared stashes, which makes
+even simple `git add path/to/file && git commit` racy.
+
+## Files added / modified (final state on origin/main)
+
+- `prisma/schema.prisma`, `prisma/migrations/0032_host_metric/migration.sql`
+- `src/lib/jobs/host-metric-sampler.ts` + `__tests__/host-metric-sampler.test.ts`
+- `src/lib/jobs/reminder-worker.ts` (sampler queue registration)
+- `src/app/api/admin/host-metrics/route.ts`
+- `tests/integration/admin-host-metrics.test.ts`
+- `src/components/admin/host-metrics-chart.tsx` + `__tests__/host-metrics-chart.test.tsx`
+- `src/components/admin/system-status-section.tsx` (wiring)
+- `messages/en.json`, `messages/de.json` (admin.hostMetrics.*)
+- `tests/integration/setup.ts` (truncate `host_metrics`)
## Did NOT touch (scope guards)
-- `src/components/admin/backups-section.tsx` and backup endpoints
- (B1's territory).
-- `src/lib/withings/`, `src/lib/moodlog/`, integrations-section.tsx
- (B2's territory).
-- `.github/workflows/*` (C3's territory).
-
-## Notes for follow-up work
-
-- `consecutive_failures` is incremented on hard-reject too (so the
- status UI can show "5 in a row before we gave up"). Re-enable
- resets to 0.
-- The reminder-worker still calls `dispatchNotification()` once per
- scheduled phase; the cooldown-skip protects against quota burn,
- but a future v1.4.16 follow-up could surface "X reminders skipped
- while channel paused" as a Wide Event metric.
-- Telegram webhook flow (`src/app/api/telegram/webhook/route.ts`)
- still uses `sendTelegramMessage` directly with the legacy
- `SendMessageResult` shape — not in scope for B3 (it's the inbound
- bot reply path, not a dispatch). The `errorDescription` field
- added in this phase is available there if a future refactor wants
- to consume it.
+- `src/lib/ai/`, `src/components/insights/`,
+ `src/components/charts/scatter-correlation-chart.tsx` (B5a/B5c-e)
+- `src/components/settings/*` (B2/B6/B7)
+- audit-log section (B4)
+- No new dependencies — Recharts pattern reuse only.
diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts
new file mode 100644
index 000000000..bceb26162
--- /dev/null
+++ b/src/app/api/export/__tests__/per-type-routes.test.ts
@@ -0,0 +1,292 @@
+/**
+ * Unit-level smoke for the per-type export endpoints
+ * (`/api/export/{measurements,medications,mood}` and
+ * `/api/export/full-backup`).
+ *
+ * Exercises the auth gate, content-type, content-disposition, audit-log
+ * action name, and rate-limit wiring. The full DB round-trip lives in
+ * the integration suite (`tests/integration/export-per-type.test.ts`).
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ measurement: { findMany: vi.fn() },
+ medication: { findMany: vi.fn() },
+ medicationIntakeEvent: { findMany: vi.fn() },
+ moodEntry: { findMany: vi.fn() },
+ user: { findUnique: vi.fn() },
+ auditLog: { create: vi.fn() },
+ },
+}));
+
+vi.mock("@/lib/auth/session", () => ({
+ getSession: vi.fn(),
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(),
+}));
+
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/geo", () => ({
+ lookupIpLocation: vi.fn(),
+}));
+
+vi.mock("@/lib/logging/transports", () => ({
+ emitIfSampled: vi.fn(),
+}));
+
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { auditLog } from "@/lib/auth/audit";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
+ user: { id: "user-1", role: "USER" as const },
+};
+
+function mkReq(url: string): NextRequest {
+ return new NextRequest(url, { method: "GET" });
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+describe("GET /api/export/measurements", () => {
+ it("rejects unauthenticated requests with 401", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+ const { GET } = await import("../measurements/route");
+ const res = await GET(mkReq("http://localhost/api/export/measurements"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns text/csv with attachment disposition on success", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 9,
+ resetAt: Date.now() + 3600_000,
+ });
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+ {
+ type: "WEIGHT",
+ value: 80,
+ unit: "kg",
+ measuredAt: new Date("2026-05-01T08:00:00.000Z"),
+ source: "MANUAL",
+ notes: null,
+ glucoseContext: null,
+ },
+ ] as never);
+
+ const { GET } = await import("../measurements/route");
+ const res = await GET(mkReq("http://localhost/api/export/measurements"));
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toMatch(/text\/csv/);
+ expect(res.headers.get("content-disposition")).toMatch(
+ /attachment; filename="healthlog-measurements-/,
+ );
+ expect(res.headers.get("content-disposition")).toMatch(/\.csv"/);
+ const body = await res.text();
+ expect(body).toContain("WEIGHT,80,kg");
+
+ expect(auditLog).toHaveBeenCalledWith(
+ "user.export.measurements",
+ expect.objectContaining({
+ userId: "user-1",
+ }),
+ );
+ });
+
+ it("honours since/until query params via measuredAt range", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 9,
+ resetAt: Date.now() + 3600_000,
+ });
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
+
+ const { GET } = await import("../measurements/route");
+ await GET(
+ mkReq(
+ "http://localhost/api/export/measurements?since=2026-04-01&until=2026-05-01",
+ ),
+ );
+ expect(prisma.measurement.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ measuredAt: expect.objectContaining({
+ gte: new Date("2026-04-01"),
+ lte: new Date("2026-05-01"),
+ }),
+ }),
+ }),
+ );
+ });
+});
+
+describe("GET /api/export/medications", () => {
+ it("rejects unauthenticated requests with 401", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+ const { GET } = await import("../medications/route");
+ const res = await GET(mkReq("http://localhost/api/export/medications"));
+ expect(res.status).toBe(401);
+ });
+
+ it("appends intake-history section when intake=true", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 9,
+ resetAt: Date.now() + 3600_000,
+ });
+ vi.mocked(prisma.medication.findMany).mockResolvedValue([
+ {
+ name: "Aspirin",
+ dose: "100mg",
+ active: true,
+ schedules: [
+ {
+ windowStart: "08:00",
+ windowEnd: "09:00",
+ label: "Morning",
+ dose: null,
+ },
+ ],
+ },
+ ] as never);
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
+ {
+ medication: { name: "Aspirin" },
+ scheduledFor: new Date("2026-05-01T08:00:00.000Z"),
+ takenAt: new Date("2026-05-01T08:05:00.000Z"),
+ skipped: false,
+ source: "WEB",
+ },
+ ] as never);
+
+ const { GET } = await import("../medications/route");
+ const res = await GET(
+ mkReq("http://localhost/api/export/medications?intake=true"),
+ );
+ const body = await res.text();
+ expect(body).toContain("Aspirin");
+ // The intake-history section is delimited so a downstream tool can split.
+ expect(body).toContain("# Intake history");
+ expect(body).toContain("scheduledFor");
+ });
+
+ it("omits intake-history section when intake=false", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 9,
+ resetAt: Date.now() + 3600_000,
+ });
+ vi.mocked(prisma.medication.findMany).mockResolvedValue([
+ { name: "X", dose: "1", active: true, schedules: [] },
+ ] as never);
+
+ const { GET } = await import("../medications/route");
+ const res = await GET(
+ mkReq("http://localhost/api/export/medications?intake=false"),
+ );
+ const body = await res.text();
+ expect(body).not.toContain("# Intake history");
+ expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
+ });
+});
+
+describe("GET /api/export/mood", () => {
+ it("rejects unauthenticated requests with 401", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+ const { GET } = await import("../mood/route");
+ const res = await GET(mkReq("http://localhost/api/export/mood"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns text/csv with mood rows on success", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 9,
+ resetAt: Date.now() + 3600_000,
+ });
+ vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
+ {
+ date: "2026-05-01",
+ mood: "good",
+ score: 4,
+ tags: null,
+ source: "WEB",
+ moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
+ },
+ ] as never);
+
+ const { GET } = await import("../mood/route");
+ const res = await GET(mkReq("http://localhost/api/export/mood"));
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toMatch(/text\/csv/);
+ const body = await res.text();
+ expect(body).toContain("good");
+ expect(auditLog).toHaveBeenCalledWith(
+ "user.export.mood",
+ expect.objectContaining({ userId: "user-1" }),
+ );
+ });
+});
+
+describe("GET /api/export/full-backup", () => {
+ it("rejects unauthenticated requests with 401", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+ const { GET } = await import("../full-backup/route");
+ const res = await GET(mkReq("http://localhost/api/export/full-backup"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns a JSON backup that matches the canonical schema", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 9,
+ resetAt: Date.now() + 3600_000,
+ });
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
+ [] as never,
+ );
+ vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
+
+ const { GET } = await import("../full-backup/route");
+ const res = await GET(mkReq("http://localhost/api/export/full-backup"));
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toMatch(/application\/json/);
+ expect(res.headers.get("content-disposition")).toMatch(
+ /attachment; filename="healthlog-backup-/,
+ );
+ expect(res.headers.get("content-disposition")).toMatch(/\.json"/);
+ const json = await res.json();
+ expect(json).toMatchObject({
+ schemaVersion: "1",
+ userId: "user-1",
+ measurements: [],
+ medications: [],
+ intakeEvents: [],
+ moodEntries: [],
+ });
+ expect(typeof json.exportedAt).toBe("string");
+ expect(auditLog).toHaveBeenCalledWith(
+ "user.export.full-backup",
+ expect.objectContaining({ userId: "user-1" }),
+ );
+ });
+});
diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts
new file mode 100644
index 000000000..5cf342878
--- /dev/null
+++ b/src/app/api/export/full-backup/route.ts
@@ -0,0 +1,151 @@
+/**
+ * GET /api/export/full-backup
+ *
+ * v1.4.16 phase B7. Single-file user-scoped JSON dump that matches the
+ * canonical `backupPayloadSchema` (see `src/lib/validations/backup.ts`)
+ * — same shape the pg-boss `data-backup` worker writes weekly, so a
+ * user can hand this file to an admin and `POST /api/admin/backups/upload`
+ * accepts it without further conversion.
+ *
+ * Response is `application/json` with an attachment filename ending in
+ * `.json` so the browser writes a `.json` file even though the route
+ * segment doesn't carry the extension.
+ *
+ * Auth: cookie session OR Bearer token (`requireAuth`).
+ * Rate-limit: shared `export:` bucket (10/h).
+ * Audit: `user.export.full-backup` with the row counts.
+ */
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { apiError, getClientIp } from "@/lib/api-response";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
+import { NextRequest, NextResponse } from "next/server";
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "user.export.full-backup" } });
+
+ const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return apiError("Maximum 10 exports per hour", 429);
+ }
+
+ const [measurements, medications, intakeEvents, moodEntries] =
+ await Promise.all([
+ prisma.measurement.findMany({
+ where: { userId: user.id },
+ orderBy: { measuredAt: "desc" },
+ }),
+ // Explicit `select` on the schedule columns — see the matching
+ // comment in `medications/route.ts` for the rationale (the
+ // integration testbed has an unmigrated `days_of_week` column on
+ // the schema branch we ship alongside).
+ prisma.medication.findMany({
+ where: { userId: user.id },
+ select: {
+ name: true,
+ dose: true,
+ active: true,
+ schedules: {
+ select: {
+ windowStart: true,
+ windowEnd: true,
+ label: true,
+ dose: true,
+ },
+ },
+ },
+ }),
+ prisma.medicationIntakeEvent.findMany({
+ where: { userId: user.id },
+ include: { medication: { select: { name: true } } },
+ orderBy: { scheduledFor: "desc" },
+ }),
+ prisma.moodEntry.findMany({
+ where: { userId: user.id },
+ orderBy: { moodLoggedAt: "desc" },
+ }),
+ ]);
+
+ // Shape mirrors the pg-boss `data-backup` worker exactly so the same
+ // `parseBackupPayload()` validator round-trips both blobs. Keep these
+ // two writers in sync — `src/lib/jobs/reminder-worker.ts` is the
+ // canonical reference.
+ const payload = {
+ schemaVersion: BACKUP_SCHEMA_VERSION,
+ exportedAt: new Date().toISOString(),
+ userId: user.id,
+ measurements: measurements.map((m) => ({
+ type: m.type,
+ value: m.value,
+ unit: m.unit,
+ measuredAt: m.measuredAt.toISOString(),
+ source: m.source,
+ notes: m.notes,
+ })),
+ medications: medications.map((m) => ({
+ name: m.name,
+ dose: m.dose,
+ active: m.active,
+ schedules: m.schedules.map((s) => ({
+ windowStart: s.windowStart,
+ windowEnd: s.windowEnd,
+ label: s.label,
+ dose: s.dose,
+ })),
+ })),
+ intakeEvents: intakeEvents.map((e) => ({
+ medication: e.medication.name,
+ scheduledFor: e.scheduledFor.toISOString(),
+ takenAt: e.takenAt?.toISOString() ?? null,
+ skipped: e.skipped,
+ source: e.source,
+ })),
+ moodEntries: moodEntries.map((e) => ({
+ date: e.date,
+ mood: e.mood,
+ score: e.score,
+ tags: e.tags,
+ source: e.source,
+ loggedAt: e.moodLoggedAt.toISOString(),
+ })),
+ };
+
+ await auditLog("user.export.full-backup", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: {
+ counts: {
+ measurements: measurements.length,
+ medications: medications.length,
+ intakeEvents: intakeEvents.length,
+ moodEntries: moodEntries.length,
+ },
+ },
+ });
+
+ annotate({
+ meta: {
+ export_measurements_count: measurements.length,
+ export_medications_count: medications.length,
+ export_intake_count: intakeEvents.length,
+ export_mood_count: moodEntries.length,
+ },
+ });
+
+ const stamp = new Date().toISOString().slice(0, 10);
+ // Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
+ // the file is a self-contained backup — admin upload + restore expect
+ // the raw payload, not `{ data: { ... } }`.
+ return new NextResponse(JSON.stringify(payload), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.json"`,
+ "Cache-Control": "no-store",
+ },
+ });
+});
diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts
new file mode 100644
index 000000000..9bb006351
--- /dev/null
+++ b/src/app/api/export/measurements/route.ts
@@ -0,0 +1,110 @@
+/**
+ * GET /api/export/measurements
+ *
+ * v1.4.16 phase B7. Per-type CSV download for the consolidated
+ * `/settings/export` UI. Filterable by `since` / `until` (inclusive
+ * date strings parsed via the standard Date constructor — invalid
+ * values are silently dropped from the where-clause).
+ *
+ * Response is `text/csv` with an attachment filename ending in `.csv`,
+ * so the browser writes a `.csv` file even though the route segment
+ * itself doesn't carry the extension (Next.js + vitest can't resolve
+ * dotted route segments cleanly — see commit log for context).
+ *
+ * Auth: cookie session OR Bearer token (`requireAuth`).
+ * Rate-limit: shared `export:` bucket (10/h) so the user can't
+ * sidestep the global cap by hitting per-type endpoints in parallel.
+ * Audit: `user.export.measurements` with the resolved filter.
+ */
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { apiError, getClientIp } from "@/lib/api-response";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { toCSV, formatMeasurementsForExport } from "@/lib/export";
+import { NextRequest, NextResponse } from "next/server";
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "user.export.measurements" } });
+
+ const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return apiError("Maximum 10 exports per hour", 429);
+ }
+
+ const { since, until } = parseRange(request.url);
+ const where = buildWhere(user.id, { since, until });
+
+ const measurements = await prisma.measurement.findMany({
+ where,
+ orderBy: { measuredAt: "desc" },
+ });
+
+ const csv = toCSV(formatMeasurementsForExport(measurements));
+
+ await auditLog("user.export.measurements", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: {
+ count: measurements.length,
+ since: since?.toISOString() ?? null,
+ until: until?.toISOString() ?? null,
+ },
+ });
+
+ annotate({
+ meta: {
+ export_count: measurements.length,
+ export_since: since?.toISOString() ?? null,
+ export_until: until?.toISOString() ?? null,
+ },
+ });
+
+ return csvResponse(csv, `healthlog-measurements-${user.id}`);
+});
+
+function parseRange(url: string): {
+ since: Date | undefined;
+ until: Date | undefined;
+} {
+ const params = new URL(url).searchParams;
+ const sinceRaw = params.get("since");
+ const untilRaw = params.get("until");
+ const since = sinceRaw ? safeDate(sinceRaw) : undefined;
+ const until = untilRaw ? safeDate(untilRaw) : undefined;
+ return { since, until };
+}
+
+function safeDate(value: string): Date | undefined {
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? undefined : d;
+}
+
+function buildWhere(
+ userId: string,
+ range: { since?: Date; until?: Date },
+): { userId: string; measuredAt?: { gte?: Date; lte?: Date } } {
+ const where: { userId: string; measuredAt?: { gte?: Date; lte?: Date } } = {
+ userId,
+ };
+ if (range.since || range.until) {
+ where.measuredAt = {};
+ if (range.since) where.measuredAt.gte = range.since;
+ if (range.until) where.measuredAt.lte = range.until;
+ }
+ return where;
+}
+
+function csvResponse(body: string, prefix: string): NextResponse {
+ const stamp = new Date().toISOString().slice(0, 10);
+ return new NextResponse(body, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="${prefix}-${stamp}.csv"`,
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/src/app/api/export/medications/route.ts b/src/app/api/export/medications/route.ts
new file mode 100644
index 000000000..6c697dfa7
--- /dev/null
+++ b/src/app/api/export/medications/route.ts
@@ -0,0 +1,133 @@
+/**
+ * GET /api/export/medications
+ *
+ * v1.4.16 phase B7. Per-type CSV download for the consolidated
+ * `/settings/export` UI. Always includes the medication list; appends
+ * a `# Intake history` block when `?intake=true` (the default — the
+ * UI toggle is checked by default so a stripped-down export is opt-in).
+ *
+ * Response is `text/csv` with an attachment filename ending in `.csv`.
+ *
+ * Auth: cookie session OR Bearer token (`requireAuth`).
+ * Rate-limit: shared `export:` bucket (10/h) so the user can't
+ * sidestep the global cap by hitting per-type endpoints in parallel.
+ * Audit: `user.export.medications` with the resolved filter + intake flag.
+ */
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { apiError, getClientIp } from "@/lib/api-response";
+import { checkRateLimit } from "@/lib/rate-limit";
+import {
+ toCSV,
+ formatMedicationsForExport,
+ formatIntakeEventsForExport,
+} from "@/lib/export";
+import { NextRequest, NextResponse } from "next/server";
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "user.export.medications" } });
+
+ const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return apiError("Maximum 10 exports per hour", 429);
+ }
+
+ const params = new URL(request.url).searchParams;
+ const includeIntake = params.get("intake") !== "false";
+ const sinceRaw = params.get("since");
+ const untilRaw = params.get("until");
+ const since = sinceRaw ? safeDate(sinceRaw) : undefined;
+ const until = untilRaw ? safeDate(untilRaw) : undefined;
+
+ // Explicit `select` on the schedule columns we actually format. The
+ // integration testbed has an unmigrated `medication_schedules.days_of_week`
+ // column on this branch (sibling work added the field to the schema
+ // without a matching migration); a wildcard `include: { schedules: true }`
+ // would 500 on the missing column. Pin the columns instead — the export
+ // shape doesn't need every future field anyway.
+ const medications = await prisma.medication.findMany({
+ where: { userId: user.id },
+ select: {
+ name: true,
+ dose: true,
+ active: true,
+ schedules: {
+ select: {
+ windowStart: true,
+ windowEnd: true,
+ label: true,
+ dose: true,
+ },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ });
+
+ const sections: string[] = [
+ "# Medications",
+ toCSV(formatMedicationsForExport(medications)),
+ ];
+ let intakeCount = 0;
+
+ if (includeIntake) {
+ const where: {
+ userId: string;
+ scheduledFor?: { gte?: Date; lte?: Date };
+ } = {
+ userId: user.id,
+ };
+ if (since || until) {
+ where.scheduledFor = {};
+ if (since) where.scheduledFor.gte = since;
+ if (until) where.scheduledFor.lte = until;
+ }
+ const events = await prisma.medicationIntakeEvent.findMany({
+ where,
+ include: { medication: { select: { name: true } } },
+ orderBy: { scheduledFor: "desc" },
+ });
+ intakeCount = events.length;
+ sections.push("# Intake history");
+ sections.push(toCSV(formatIntakeEventsForExport(events)));
+ }
+
+ const body = sections.join("\n\n");
+
+ await auditLog("user.export.medications", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: {
+ medicationCount: medications.length,
+ intakeCount,
+ includeIntake,
+ since: since?.toISOString() ?? null,
+ until: until?.toISOString() ?? null,
+ },
+ });
+
+ annotate({
+ meta: {
+ export_medications_count: medications.length,
+ export_intake_count: intakeCount,
+ export_include_intake: includeIntake,
+ },
+ });
+
+ const stamp = new Date().toISOString().slice(0, 10);
+ return new NextResponse(body, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="healthlog-medications-${user.id}-${stamp}.csv"`,
+ "Cache-Control": "no-store",
+ },
+ });
+});
+
+function safeDate(value: string): Date | undefined {
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? undefined : d;
+}
diff --git a/src/app/api/export/mood/route.ts b/src/app/api/export/mood/route.ts
new file mode 100644
index 000000000..69a395827
--- /dev/null
+++ b/src/app/api/export/mood/route.ts
@@ -0,0 +1,90 @@
+/**
+ * GET /api/export/mood
+ *
+ * v1.4.16 phase B7. Per-type CSV download for the consolidated
+ * `/settings/export` UI. Filterable by `since` / `until` (inclusive
+ * date strings parsed via the standard Date constructor — invalid
+ * values are silently dropped from the where-clause).
+ *
+ * Response is `text/csv` with an attachment filename ending in `.csv`.
+ *
+ * Auth: cookie session OR Bearer token (`requireAuth`).
+ * Rate-limit: shared `export:` bucket (10/h).
+ * Audit: `user.export.mood` with the resolved filter.
+ */
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { apiError, getClientIp } from "@/lib/api-response";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { toCSV, formatMoodEntriesForExport } from "@/lib/export";
+import { NextRequest, NextResponse } from "next/server";
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "user.export.mood" } });
+
+ const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return apiError("Maximum 10 exports per hour", 429);
+ }
+
+ const params = new URL(request.url).searchParams;
+ const since = params.get("since")
+ ? safeDate(params.get("since")!)
+ : undefined;
+ const until = params.get("until")
+ ? safeDate(params.get("until")!)
+ : undefined;
+
+ const where: {
+ userId: string;
+ moodLoggedAt?: { gte?: Date; lte?: Date };
+ } = {
+ userId: user.id,
+ };
+ if (since || until) {
+ where.moodLoggedAt = {};
+ if (since) where.moodLoggedAt.gte = since;
+ if (until) where.moodLoggedAt.lte = until;
+ }
+
+ const entries = await prisma.moodEntry.findMany({
+ where,
+ orderBy: { moodLoggedAt: "desc" },
+ });
+
+ const csv = toCSV(formatMoodEntriesForExport(entries));
+
+ await auditLog("user.export.mood", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: {
+ count: entries.length,
+ since: since?.toISOString() ?? null,
+ until: until?.toISOString() ?? null,
+ },
+ });
+
+ annotate({
+ meta: {
+ export_count: entries.length,
+ },
+ });
+
+ const stamp = new Date().toISOString().slice(0, 10);
+ return new NextResponse(csv, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="healthlog-mood-${user.id}-${stamp}.csv"`,
+ "Cache-Control": "no-store",
+ },
+ });
+});
+
+function safeDate(value: string): Date | undefined {
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? undefined : d;
+}
diff --git a/tests/integration/export-per-type.test.ts b/tests/integration/export-per-type.test.ts
new file mode 100644
index 000000000..f0a2b5a5c
--- /dev/null
+++ b/tests/integration/export-per-type.test.ts
@@ -0,0 +1,326 @@
+/**
+ * Integration regression guard for the per-type export endpoints
+ * (v1.4.16 phase B7):
+ *
+ * GET /api/export/measurements.csv
+ * GET /api/export/medications.csv
+ * GET /api/export/mood.csv
+ * GET /api/export/full-backup.json
+ *
+ * Each endpoint:
+ * 1. Is gated by `requireAuth()`.
+ * 2. Returns the right content-type + attachment disposition.
+ * 3. Writes a `user.export.` audit-log entry.
+ * 4. Round-trips real DB rows for the authenticated user only
+ * (a different user's data must NOT leak).
+ *
+ * The full-backup endpoint additionally has its body validated against
+ * the canonical `backupPayloadSchema` so the file the user downloads
+ * is the same shape `POST /api/admin/backups/upload` accepts.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+async function seedUserSession(username = "export-user") {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: {
+ username,
+ email: `${username}@example.test`,
+ role: "USER",
+ },
+ });
+ const session = await prisma.session.create({
+ data: {
+ userId: user.id,
+ expiresAt: new Date(Date.now() + 60_000),
+ },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return user;
+}
+
+describe("GET /api/export/measurements.csv", () => {
+ it("returns 401 without a session", async () => {
+ const { GET } = await import("@/app/api/export/measurements.csv/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/measurements.csv", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 200 + CSV body for the authed user only (no cross-tenant leak)", async () => {
+ const prisma = getPrismaClient();
+ const me = await seedUserSession("alice");
+ const other = await prisma.user.create({
+ data: {
+ username: "bob",
+ email: "bob@example.test",
+ role: "USER",
+ },
+ });
+
+ await prisma.measurement.createMany({
+ data: [
+ {
+ userId: me.id,
+ type: "WEIGHT",
+ value: 80,
+ unit: "kg",
+ measuredAt: new Date("2026-05-01T08:00:00.000Z"),
+ source: "MANUAL",
+ },
+ {
+ userId: other.id,
+ type: "WEIGHT",
+ value: 999,
+ unit: "kg",
+ measuredAt: new Date("2026-05-01T08:00:00.000Z"),
+ source: "MANUAL",
+ },
+ ],
+ });
+
+ const { GET } = await import("@/app/api/export/measurements.csv/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/measurements.csv", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toMatch(/text\/csv/);
+ expect(res.headers.get("content-disposition")).toMatch(
+ /attachment; filename=.*healthlog-measurements/,
+ );
+
+ const body = await res.text();
+ expect(body).toContain("WEIGHT,80,kg");
+ // Bob's row must never leak into Alice's export.
+ expect(body).not.toContain("999");
+
+ const audits = await prisma.auditLog.findMany({
+ where: { userId: me.id, action: "user.export.measurements" },
+ });
+ expect(audits.length).toBe(1);
+ });
+});
+
+describe("GET /api/export/medications.csv", () => {
+ it("returns 401 without a session", async () => {
+ const { GET } = await import("@/app/api/export/medications.csv/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/medications.csv", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("includes intake history when intake=true (default)", async () => {
+ const prisma = getPrismaClient();
+ const me = await seedUserSession("med-user");
+ const med = await prisma.medication.create({
+ data: {
+ userId: me.id,
+ name: "Aspirin",
+ dose: "100mg",
+ active: true,
+ },
+ });
+ await prisma.medicationIntakeEvent.create({
+ data: {
+ userId: me.id,
+ medicationId: med.id,
+ scheduledFor: new Date("2026-05-01T08:00:00.000Z"),
+ takenAt: new Date("2026-05-01T08:05:00.000Z"),
+ skipped: false,
+ // IntakeSource enum values: WEB, API, REMINDER, IMPORT
+ source: "WEB",
+ },
+ });
+
+ const { GET } = await import("@/app/api/export/medications.csv/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/medications.csv", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(200);
+ const body = await res.text();
+ expect(body).toContain("Aspirin");
+ expect(body).toContain("# Intake history");
+ expect(body).toContain("scheduledFor");
+
+ const audits = await prisma.auditLog.findMany({
+ where: { userId: me.id, action: "user.export.medications" },
+ });
+ expect(audits.length).toBe(1);
+ });
+
+ it("omits intake history when intake=false", async () => {
+ const prisma = getPrismaClient();
+ const me = await seedUserSession("med-user-2");
+ await prisma.medication.create({
+ data: {
+ userId: me.id,
+ name: "Ibuprofen",
+ dose: "200mg",
+ active: true,
+ },
+ });
+
+ const { GET } = await import("@/app/api/export/medications.csv/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/medications.csv?intake=false", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(200);
+ const body = await res.text();
+ expect(body).toContain("Ibuprofen");
+ expect(body).not.toContain("# Intake history");
+ });
+});
+
+describe("GET /api/export/mood.csv", () => {
+ it("returns 401 without a session", async () => {
+ const { GET } = await import("@/app/api/export/mood.csv/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/mood.csv", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns the user's mood entries as CSV", async () => {
+ const prisma = getPrismaClient();
+ const me = await seedUserSession("mood-user");
+ await prisma.moodEntry.create({
+ data: {
+ userId: me.id,
+ date: "2026-05-01",
+ mood: "good",
+ score: 4,
+ // MoodEntry.source is a plain string column; "WEB" matches the
+ // values the rest of the suite uses.
+ source: "WEB",
+ moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
+ },
+ });
+
+ const { GET } = await import("@/app/api/export/mood.csv/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/mood.csv", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toMatch(/text\/csv/);
+ const body = await res.text();
+ expect(body).toContain("good");
+ expect(body).toContain("2026-05-01");
+
+ const audits = await prisma.auditLog.findMany({
+ where: { userId: me.id, action: "user.export.mood" },
+ });
+ expect(audits.length).toBe(1);
+ });
+});
+
+describe("GET /api/export/full-backup.json", () => {
+ it("returns 401 without a session", async () => {
+ const { GET } = await import("@/app/api/export/full-backup.json/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/full-backup.json", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns a JSON blob that round-trips through `parseBackupPayload`", async () => {
+ const { parseBackupPayload } = await import("@/lib/validations/backup");
+ const prisma = getPrismaClient();
+ const me = await seedUserSession("backup-user");
+
+ await prisma.measurement.create({
+ data: {
+ userId: me.id,
+ type: "WEIGHT",
+ value: 75,
+ unit: "kg",
+ measuredAt: new Date("2026-05-01T08:00:00.000Z"),
+ source: "MANUAL",
+ },
+ });
+ await prisma.moodEntry.create({
+ data: {
+ userId: me.id,
+ date: "2026-05-01",
+ mood: "great",
+ score: 5,
+ source: "WEB",
+ moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
+ },
+ });
+
+ const { GET } = await import("@/app/api/export/full-backup.json/route");
+ const res = await GET(
+ new Request("http://localhost/api/export/full-backup.json", {
+ method: "GET",
+ }) as Parameters[0],
+ );
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toMatch(/application\/json/);
+ expect(res.headers.get("content-disposition")).toMatch(
+ /attachment; filename=.*healthlog-backup/,
+ );
+
+ const text = await res.text();
+ const parsed = parseBackupPayload(text);
+ expect(parsed.userId).toBe(me.id);
+ expect(parsed.measurements.length).toBe(1);
+ expect(parsed.moodEntries.length).toBe(1);
+
+ const audits = await prisma.auditLog.findMany({
+ where: { userId: me.id, action: "user.export.full-backup" },
+ });
+ expect(audits.length).toBe(1);
+ });
+});