fix(jobs): one bad medication never stops the auto-take pass

A row whose follow-up threw, or whose stored time zone the runtime
refuses, aborted the pass for every other person. Each medication now
runs on its own: a failure is named in a warning, counted, and the next
one runs; an unusable zone falls back to the default as in other jobs.
This commit is contained in:
Marc-André Bombeck committed 2026-10-11 15:19:08 +02:00
1 parent 88aec7edb6
commit 5bfdc9d04b
3 files changed
+116 -45

No files matched your search

@@ -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);
});
});
+75 -45
View File
@@ -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<string>();
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<string>();
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<string>();
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: {
@@ -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,