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); + }); +});