mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
bd811eb753
commit
13039eb430
2 files changed
+106
-40
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user