mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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