fix(import): write an imported medication and its category together

The medication CSV import created the medication and then set its
category in a second write. A crash between the two left a medication
without a category, and a retry skipped it as a duplicate, so it never
got one. Both writes now share one transaction.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 00:39:59 +02:00
1 parent bd811eb753
commit 13039eb430
2 files changed
+106 -40

No files matched your search

+43 -39
View File
@@ -284,48 +284,52 @@ export async function importMedicationsCsv(args: {
const data = parsed.data;
// Field by field, with the create route's invariants for the shapes an
// import produces: a daily schedule (FREQ=DAILY, times dual-written), an
// opening course for a dated window, no schedule for as-needed.
const medication = await prisma.medication.create({
data: {
userId,
name: data.name,
dose: data.dose,
asNeeded: data.asNeeded ?? false,
notificationsEnabled: data.notificationsEnabled ?? false,
...(course
? {
startsOn: course.startsOn,
endsOn: course.endsOn,
courses: {
create: {
userId,
startsOn: course.startsOn,
endsOn: course.endsOn,
// opening course for a dated window, no schedule for as-needed. The
// category is written in the same transaction: a crash between the two
// left a medication without one, and the retry skips it as a duplicate.
await prisma.$transaction(async (tx) => {
const medication = await tx.medication.create({
data: {
userId,
name: data.name,
dose: data.dose,
asNeeded: data.asNeeded ?? false,
notificationsEnabled: data.notificationsEnabled ?? false,
...(course
? {
startsOn: course.startsOn,
endsOn: course.endsOn,
courses: {
create: {
userId,
startsOn: course.startsOn,
endsOn: course.endsOn,
},
},
},
}
: {}),
schedules: {
create: (data.schedules ?? []).map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: null,
dose: null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: [],
intervalWeeks: 1,
}),
timesOfDay:
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart],
rrule: "FREQ=DAILY",
})),
}
: {}),
schedules: {
create: (data.schedules ?? []).map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: null,
dose: null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: [],
intervalWeeks: 1,
}),
timesOfDay:
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart],
rrule: "FREQ=DAILY",
})),
},
},
},
select: { id: true },
select: { id: true },
});
await setMedicationCategory(medication.id, undefined, tx);
});
await setMedicationCategory(medication.id, undefined);
written += 1;
args.onWritten?.(1);
}
+63 -1
View File
@@ -8,7 +8,8 @@
* - re-uploading the same file writes nothing: every row is a duplicate;
* - a refused row is reported by line and does not stop the rest;
* - medications take a daily schedule, reminders off, as-needed without
* schedule, and a dated window opens a course.
* schedule, and a dated window opens a course;
* - a medication and its category land together or not at all.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
@@ -27,6 +28,20 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
const categoryFault = vi.hoisted(() => ({ on: false }));
vi.mock("@/lib/medication-category", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/medication-category")>();
return {
...actual,
setMedicationCategory: vi.fn(
async (...args: Parameters<typeof actual.setMedicationCategory>) => {
if (categoryFault.on) throw new Error("category write failed");
return actual.setMedicationCategory(...args);
},
),
};
});
const prisma = getPrismaClient();
const USER = "csv-import-user";
@@ -367,3 +382,50 @@ describe("medication CSV import", () => {
expect(again).toMatchObject({ inserted: 0, duplicates: 4 });
});
});
describe("medication CSV import, a crash between medication and category", () => {
const CSV = ["name,dose,times", "Atorvastatin,20 mg,20:00"].join("\n");
const query = medicationsCsvQuerySchema.parse({
name: "0",
dose: "1",
times: "2",
dryRun: "0",
});
it("leaves no medication without a category, so the retry writes it whole", async () => {
categoryFault.on = true;
await expect(
importMedicationsCsv({
userId: USER,
tz: TZ,
stream: textToStream(CSV),
maxBytes: 100_000,
query,
}),
).rejects.toThrow("category write failed");
categoryFault.on = false;
expect(
await prisma.medication.count({
where: { userId: USER, name: "Atorvastatin" },
}),
).toBe(0);
const retry = await importMedicationsCsv({
userId: USER,
tz: TZ,
stream: textToStream(CSV),
maxBytes: 100_000,
query,
});
expect(retry).toMatchObject({ inserted: 1, duplicates: 0 });
const med = await prisma.medication.findFirstOrThrow({
where: { userId: USER, name: "Atorvastatin" },
select: { id: true },
});
expect(
await prisma.medicationCategoryAssignment.count({
where: { medicationId: med.id },
}),
).toBe(1);
});
});