mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user