From 13039eb430ddbd30bc47328c81ee19f7b5a2005d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:39:59 +0200 Subject: [PATCH] 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. --- src/lib/import/medications-csv.ts | 82 +++++++++++---------- tests/integration/record-csv-import.test.ts | 64 +++++++++++++++- 2 files changed, 106 insertions(+), 40 deletions(-) diff --git a/src/lib/import/medications-csv.ts b/src/lib/import/medications-csv.ts index 354bfab13..c3d3b848e 100644 --- a/src/lib/import/medications-csv.ts +++ b/src/lib/import/medications-csv.ts @@ -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); } diff --git a/tests/integration/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts index 7e28273ab..de25d0251 100644 --- a/tests/integration/record-csv-import.test.ts +++ b/tests/integration/record-csv-import.test.ts @@ -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(); + return { + ...actual, + setMedicationCategory: vi.fn( + async (...args: Parameters) => { + 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); + }); +});