mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Postgres returns rows that tie on an ORDER BY in no particular order, and not the same one twice. The medication reads ordered by columns two rows share all the time: every read that hands schedules on included them with no order at all, medications were listed by createdAt alone (one import writes many in a millisecond), intakes by scheduledFor (a twice-daily medication puts every morning dose on one instant), and "the latest intake" lookups by takenAt. A client read the same account in a different order from one request to the next, and a latest-intake lookup picked either of two rows. Every findMany / findFirst on a medication table, nested includes included, now ends its order on the id in the direction of its last key, and schedules are read by window, then id (MEDICATION_SCHEDULE_ORDER, the order the backup already wrote). medication-read-order-guard.test.ts holds the tree to it. medication-read-order.test.ts writes tying rows in the opposite order to the one each read promises, through the medication list and today's intakes, and fails without the tie-breakers. The parity suite's own state reader had the same gap: it sorts rows by content with the ids blanked, and rows that tie then kept whatever order the unordered read returned, so their <id:N> labels swapped under a loaded run. Its reads are ordered by id, which for a cuid is the order the rows were written in. The JSON import's schema left the route, so its entry in the inline schema strictness register goes; the schema module says why its objects are not strict.
164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
/**
|
|
* 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).
|
|
*
|
|
* `?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`).
|
|
* Rate-limit: shared `export:<userId>` 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 { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
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 medicationId = params.get("medicationId") || undefined;
|
|
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, userTz] = await Promise.all([
|
|
prisma.medication.findMany({
|
|
where: medicationId
|
|
? { userId: user.id, id: medicationId }
|
|
: { userId: user.id },
|
|
select: {
|
|
name: true,
|
|
dose: true,
|
|
active: true,
|
|
trackIntake: true,
|
|
schedules: {
|
|
select: {
|
|
windowStart: true,
|
|
windowEnd: true,
|
|
label: true,
|
|
dose: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
}),
|
|
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)),
|
|
];
|
|
let intakeCount = 0;
|
|
|
|
if (includeIntake) {
|
|
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;
|
|
if (until) where.scheduledFor.lte = until;
|
|
}
|
|
const events = await prisma.medicationIntakeEvent.findMany({
|
|
where,
|
|
include: { medication: { select: { name: true } } },
|
|
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
|
|
});
|
|
intakeCount = events.length;
|
|
sections.push("# Intake history");
|
|
sections.push(toCSV(formatIntakeEventsForExport(events, userTz)));
|
|
}
|
|
|
|
const body = sections.join("\n\n");
|
|
|
|
await auditLog("user.export.medications", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: {
|
|
medicationCount: medications.length,
|
|
intakeCount,
|
|
includeIntake,
|
|
medicationId: medicationId ?? null,
|
|
since: since?.toISOString() ?? null,
|
|
until: until?.toISOString() ?? null,
|
|
},
|
|
});
|
|
|
|
annotate({
|
|
meta: {
|
|
export_medications_count: medications.length,
|
|
export_intake_count: intakeCount,
|
|
export_include_intake: includeIntake,
|
|
export_medication_scoped: Boolean(medicationId),
|
|
},
|
|
});
|
|
|
|
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
|
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;
|
|
}
|