From 24ffca722066b21a372348dc1fb98e1ec2a5ff2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 31 May 2026 20:22:40 +0200 Subject: [PATCH] feat(meds): scope the advanced-settings export to the single medication The advanced sheet sits on one medication and its import already loads that medication's intake history, but the co-located export streamed every medication. Pass the medication id through to the export route, which narrows both the medication row and the intake history by it (always alongside userId, so a foreign id 404s rather than leaking). --- CHANGELOG.md | 2 +- .../export/__tests__/per-type-routes.test.ts | 53 +++++++++++++++++++ src/app/api/export/medications/route.ts | 23 +++++++- .../sections/data-portability-row.tsx | 9 ++-- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dbb1a2ec..56bed7227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ A focused follow-up to v1.7.0. The unified dashboard snapshot that shipped in v1 - **Coach data-source rail matches the settings sheet** — the in-chat rail now toggles the same persisted data clusters as the gear-icon settings, stored per user rather than reset when the panel closes. The chat request derives its scope from those saved clusters, so the rail always reflects exactly what is sent to the model, and the analysis window persists alongside. - **Medication card actions in one menu** — the overview cards, standard and GLP-1 alike, move their edit / history / advanced actions into a single overflow menu; the card itself opens the detail page. - **Medication detail page is history-first** — the redundant header action row and the schedule block are gone; the page leads with the intake history under a read-only summary (name, dose, status, plain-language cadence), and editing happens from the card menu. The estimated active-ingredient curve stays an opt-in disclosure. -- **Advanced-settings sheet widened** — a two-column layout (Data, Reminders, Lifecycle, with the danger zone full-width) so the sheet no longer scrolls on a desktop viewport, and a medications CSV export sits beside the intake import. +- **Advanced-settings sheet widened** — a two-column layout (Data, Reminders, Lifecycle, with the danger zone full-width) so the sheet no longer scrolls on a desktop viewport, and a CSV export of that medication (its row plus intake history) sits beside the per-medication intake import. ### Fixed diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index bceb26162..141559a27 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -203,6 +203,59 @@ describe("GET /api/export/medications", () => { expect(body).not.toContain("# Intake history"); expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); }); + + it("scopes both queries to a single medication when medicationId is set", 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: [] }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + + const { GET } = await import("../medications/route"); + const res = await GET( + mkReq( + "http://localhost/api/export/medications?intake=true&medicationId=med-9", + ), + ); + expect(res.status).toBe(200); + // The medication list is narrowed by id AND userId (IDOR-safe). + expect(prisma.medication.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", id: "med-9" }, + }), + ); + // The intake history is filtered to the same medication. + expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ medicationId: "med-9" }), + }), + ); + }); + + it("404s a scoped export when the medication is not the caller's", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 3600_000, + }); + // userId-narrowed query resolves to no row for a foreign id. + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + + const { GET } = await import("../medications/route"); + const res = await GET( + mkReq("http://localhost/api/export/medications?medicationId=not-mine"), + ); + expect(res.status).toBe(404); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + }); }); describe("GET /api/export/mood", () => { diff --git a/src/app/api/export/medications/route.ts b/src/app/api/export/medications/route.ts index 689593512..65231503f 100644 --- a/src/app/api/export/medications/route.ts +++ b/src/app/api/export/medications/route.ts @@ -6,6 +6,13 @@ * a `# Intake history` block when `?intake=true` (the default — the * UI toggle is checked by default so a stripped-down export is opt-in). * + * `?medicationId=` scopes the export to a single medication (its row + * plus, when intake is on, only that medication's intake log) — the + * per-medication advanced sheet uses it so the export mirrors the + * per-medication import beside it. Omitted, the export spans every + * medication (the `/settings/export` card). The id is always narrowed by + * `userId`, so a foreign id resolves to no medication and 404s. + * * Response is `text/csv` with an attachment filename ending in `.csv`. * * Auth: cookie session OR Bearer token (`requireAuth`). @@ -38,6 +45,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const params = new URL(request.url).searchParams; const includeIntake = params.get("intake") !== "false"; + const medicationId = params.get("medicationId") || undefined; const sinceRaw = params.get("since"); const untilRaw = params.get("until"); const since = sinceRaw ? safeDate(sinceRaw) : undefined; @@ -51,7 +59,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // shape doesn't need every future field anyway. const [medications, userTz] = await Promise.all([ prisma.medication.findMany({ - where: { userId: user.id }, + where: medicationId + ? { userId: user.id, id: medicationId } + : { userId: user.id }, select: { name: true, dose: true, @@ -70,6 +80,13 @@ export const GET = apiHandler(async (request: NextRequest) => { resolveUserTimezone(user.id), ]); + // A scoped export for an id the caller doesn't own resolves to no row + // (the `where` is narrowed by userId) — surface that as 404 rather than + // streaming an empty CSV. + if (medicationId && medications.length === 0) { + return apiError("Medication not found", 404); + } + const sections: string[] = [ "# Medications", toCSV(formatMedicationsForExport(medications)), @@ -80,12 +97,14 @@ export const GET = apiHandler(async (request: NextRequest) => { const where: { userId: string; deletedAt: null; + medicationId?: string; scheduledFor?: { gte?: Date; lte?: Date }; } = { userId: user.id, // v1.7.0 sync — exclude tombstoned rows from the export. deletedAt: null, }; + if (medicationId) where.medicationId = medicationId; if (since || until) { where.scheduledFor = {}; if (since) where.scheduledFor.gte = since; @@ -110,6 +129,7 @@ export const GET = apiHandler(async (request: NextRequest) => { medicationCount: medications.length, intakeCount, includeIntake, + medicationId: medicationId ?? null, since: since?.toISOString() ?? null, until: until?.toISOString() ?? null, }, @@ -120,6 +140,7 @@ export const GET = apiHandler(async (request: NextRequest) => { export_medications_count: medications.length, export_intake_count: intakeCount, export_include_intake: includeIntake, + export_medication_scoped: Boolean(medicationId), }, }); diff --git a/src/components/medications/sections/data-portability-row.tsx b/src/components/medications/sections/data-portability-row.tsx index 60e8b63d1..93fd04a15 100644 --- a/src/components/medications/sections/data-portability-row.tsx +++ b/src/components/medications/sections/data-portability-row.tsx @@ -61,11 +61,14 @@ export function DataPortabilityRow({ setBusy(true); setError(null); try { - const query = includeIntake ? "?intake=true" : "?intake=false"; + const query = new URLSearchParams({ + intake: includeIntake ? "true" : "false", + medicationId, + }).toString(); const stamp = new Date().toISOString().slice(0, 10); await downloadFromUrl( - `/api/export/medications${query}`, - `healthlog-medications-${stamp}.csv`, + `/api/export/medications?${query}`, + `healthlog-medication-${stamp}.csv`, ); } catch (err) { setError(err instanceof Error ? err.message : String(err));