diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index a2daeada1..dbcb3e0a9 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -989,3 +989,43 @@ describe("runIntakeAutoTakePass — nearby takes of another slot", () => { ).toEqual({ kind: "leave", reason: "taken_nearby" }); }); }); + +// ── one bad row never stops the pass ────────────────────────────────── + +describe("runIntakeAutoTakePass — per-medication isolation", () => { + it("a medication whose follow-up throws is skipped, the next one still runs", async () => { + const broken = medication({ + id: "m-broken", + autoMarkTakenSince: new Date("2026-06-10T00:00:00Z"), + }); + const fine = medication({ + id: "m-fine", + autoMarkTakenSince: new Date("2026-06-10T00:00:00Z"), + }); + const rows: FakeRow[] = []; + vi.mocked(consumeForIntake).mockImplementationOnce(async () => { + throw new Error("inventory unavailable"); + }); + const result = await runIntakeAutoTakePass( + fakeClient([broken, fine], rows), + { nowMs: Date.parse("2026-06-10T17:20:00Z") }, + ); + expect(result.failedCount).toBe(1); + expect( + rows.filter((r) => r.medicationId === "m-fine" && r.autoRecorded), + ).toHaveLength(1); + }); + + it("a stored zone the runtime refuses falls back instead of throwing", async () => { + const med = { + ...medication({ autoMarkTakenSince: new Date("2026-06-10T00:00:00Z") }), + user: { timezone: "Mars/Olympus_Mons" }, + }; + const rows: FakeRow[] = []; + const result = await runIntakeAutoTakePass(fakeClient([med], rows), { + nowMs: Date.parse("2026-06-10T20:00:00Z"), + }); + expect(result.failedCount).toBe(0); + expect(result.recordedCount).toBe(1); + }); +}); diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 6df52c701..e8d93e765 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -86,7 +86,8 @@ import { dayKeyForScheduledFor, recomputeMedicationComplianceForEvent, } from "@/lib/rollups/medication-compliance-rollups"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, validTimezoneOr } from "@/lib/tz/format"; +import { getEvent } from "@/lib/logging/context"; export { autoTakeFloor, @@ -126,6 +127,8 @@ export interface IntakeAutoTakeResult { recordedCount: number; /** Of those, pending placeholders converted in place. */ convertedCount: number; + /** Medications whose share of the pass failed and was skipped. */ + failedCount: number; /** Users whose caches were dropped because a dose was recorded. */ userIds: string[]; } @@ -289,56 +292,27 @@ export async function runIntakeAutoTakePass( let recordedCount = 0; let convertedCount = 0; + let failedCount = 0; const touchedUsers = new Set(); for (const medication of medications) { if (!medication.autoMarkTakenSince || medication.schedules.length === 0) { continue; } - const userTz = medication.user.timezone || DEFAULT_TIMEZONE; - const floor = autoTakeFloor({ - autoMarkTakenSince: medication.autoMarkTakenSince, - createdAt: medication.createdAt, - now, - }); - const rolling = medication.schedules.some( - (s) => s.rollingIntervalDays !== null, - ); - - const outcome = await autoTakeMedication(prisma, { - medication, - userTz, - floor, - now, - rolling, - }); - recordedCount += outcome.recorded.length; - convertedCount += outcome.converted; - if (outcome.recorded.length === 0) continue; - - touchedUsers.add(medication.userId); - // The same follow-ups the intake routes run after a manual take. - const days = new Set(); - for (const slot of outcome.recorded) { - await consumeForIntake({ - client: prisma, - userId: medication.userId, - medicationId: medication.id, - eventId: slot.eventId, - intakeAt: slot.at, - }); - const dayKey = dayKeyForScheduledFor(slot.at, userTz); - if (days.has(dayKey)) continue; - days.add(dayKey); - await recomputeMedicationComplianceForEvent({ - userId: medication.userId, - medicationId: medication.id, - scheduledFor: slot.at, - tz: userTz, - }); - } - if (medication.oneShot) { - await reconcileOneShotState(prisma, medication.id, medication.userId); + // One bad row never stops the pass for everyone else: it is named in a + // warning and the next medication runs. + try { + const outcome = await autoTakeOne(prisma, medication, now); + recordedCount += outcome.recorded; + convertedCount += outcome.converted; + if (outcome.recorded > 0) touchedUsers.add(medication.userId); + } catch (err) { + failedCount += 1; + getEvent()?.addWarning( + `intake-auto-take failed for medication ${medication.id}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); } } @@ -353,10 +327,66 @@ export async function runIntakeAutoTakePass( medicationCount: medications.length, recordedCount, convertedCount, + failedCount, userIds: [...touchedUsers], }; } +/** One medication's share of a pass: record its due slots, then follow up. */ +async function autoTakeOne( + prisma: AutoTakeClient, + medication: AutoTakeMedication, + now: Date, +): Promise<{ recorded: number; converted: number }> { + // A stored zone the runtime refuses (or a bare offset) falls back to the + // default, as in every other job, rather than throwing mid-pass. + const userTz = validTimezoneOr(medication.user.timezone, DEFAULT_TIMEZONE); + const floor = autoTakeFloor({ + autoMarkTakenSince: medication.autoMarkTakenSince!, + createdAt: medication.createdAt, + now, + }); + const rolling = medication.schedules.some( + (s) => s.rollingIntervalDays !== null, + ); + + const outcome = await autoTakeMedication(prisma, { + medication, + userTz, + floor, + now, + rolling, + }); + if (outcome.recorded.length === 0) { + return { recorded: 0, converted: outcome.converted }; + } + + // The same follow-ups the intake routes run after a manual take. + const days = new Set(); + for (const slot of outcome.recorded) { + await consumeForIntake({ + client: prisma, + userId: medication.userId, + medicationId: medication.id, + eventId: slot.eventId, + intakeAt: slot.at, + }); + const dayKey = dayKeyForScheduledFor(slot.at, userTz); + if (days.has(dayKey)) continue; + days.add(dayKey); + await recomputeMedicationComplianceForEvent({ + userId: medication.userId, + medicationId: medication.id, + scheduledFor: slot.at, + tz: userTz, + }); + } + if (medication.oneShot) { + await reconcileOneShotState(prisma, medication.id, medication.userId); + } + return { recorded: outcome.recorded.length, converted: outcome.converted }; +} + async function autoTakeMedication( prisma: AutoTakeClient, input: { diff --git a/src/lib/jobs/reminder/medication-maintenance.ts b/src/lib/jobs/reminder/medication-maintenance.ts index f75b5d042..0d70064ac 100644 --- a/src/lib/jobs/reminder/medication-maintenance.ts +++ b/src/lib/jobs/reminder/medication-maintenance.ts @@ -99,6 +99,7 @@ export async function handleIntakeAutoTake( evt.addMeta("intake_auto_take_medications", result.medicationCount); evt.addMeta("intake_auto_take_recorded", result.recordedCount); evt.addMeta("intake_auto_take_converted", result.convertedCount); + evt.addMeta("intake_auto_take_failed", result.failedCount); return jobDone({ intake_auto_take_medications: result.medicationCount, intake_auto_take_recorded: result.recordedCount,