mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user