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).
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 20:22:40 +02:00
1 parent 8363db4146
commit 24ffca7220
4 files changed
+82 -5

No files matched your search

@@ -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", () => {
+22 -1
View File
@@ -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=<id>` 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),
},
});
@@ -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));