fix(telegram): pick the running course and judge a reminder by its slot

The "taken <name>" keyword looked up the first medication of that name
and only then asked whether its course had ended, so an old ended
course beside the current one of the same name answered "not found".
The lookup now reads every match and takes the first that runs.

A reminder button judged the course at the moment of the tap, so the
last evening dose of a course, confirmed after midnight, was refused
although the web still takes it. When the message names its slot, the
course is now judged at that slot. The handler file grows by six lines;
its size baseline is raised to match.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:59:53 +01:00
1 parent d79060e26b
commit df47980454
5 files changed
+100 -42

No files matched your search

@@ -82,7 +82,7 @@
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 806,
"lib/support/report.ts": 903,
"lib/telegram-webhook-handlers.ts": 1313,
"lib/telegram-webhook-handlers.ts": 1319,
"lib/validations/inbound-documents.ts": 1018,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 933,
@@ -132,7 +132,9 @@ const MEDICATION = { id: "med-1", name: "Ramipril" };
* schedule projection (distinguishable by `select.schedules`); every other
* `medication.findFirst` in the webhook wants the bare id/name shape. The
* default projection carries no schedules, so the generic dispatch tests
* exercise the ad-hoc (standalone create) path unchanged.
* exercise the ad-hoc (standalone create) path unchanged. The dose lookup
* (`findTelegramDoseMedication`) reads every match by id or name through
* `findMany`; a list read (no id, no name) finds nothing unless a test says.
*/
function wireMedicationFindFirst(
schedules: unknown[] = [],
@@ -155,6 +157,14 @@ function wireMedicationFindFirst(
}
return Promise.resolve(medication);
}) as never);
vi.mocked(prisma.medication.findMany).mockImplementation(((args: {
where?: Record<string, unknown>;
}) =>
Promise.resolve(
args?.where && ("id" in args.where || "name" in args.where)
? [medication]
: [],
)) as never);
}
beforeEach(() => {
@@ -168,7 +178,6 @@ beforeEach(() => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
wireMedicationFindFirst();
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never);
@@ -305,6 +314,7 @@ describe("Telegram webhook — callback dispatch", () => {
expect(res.status).toBe(200);
expect(prisma.medication.findFirst).not.toHaveBeenCalled();
expect(prisma.medication.findMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medication.update).not.toHaveBeenCalled();
expect(deleteMessage).not.toHaveBeenCalled();
@@ -469,7 +479,7 @@ describe("Telegram webhook — callback dispatch", () => {
});
it("rejects 'taken' for an inactive medication and acks with the localised error", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
@@ -487,7 +497,7 @@ describe("Telegram webhook — callback dispatch", () => {
});
it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never);
const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60")));
expect(res.status).toBe(200);
expect(prisma.medication.update).not.toHaveBeenCalled();
@@ -675,8 +685,8 @@ describe("Telegram webhook — text-message dispatch", () => {
}),
);
expect(res.status).toBe(200);
// markMedicationTaken → findFirst on active medication
expect(prisma.medication.findFirst).toHaveBeenCalled();
// The list, then markMedicationTaken's lookup of the one it found.
expect(prisma.medication.findMany).toHaveBeenCalledTimes(2);
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
});
@@ -717,15 +727,6 @@ describe("Telegram webhook — text-message dispatch", () => {
});
it("'taken Ramipril' (text command) creates an intake event for the matched medication", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
} as never);
// Second findFirst inside markMedicationTaken
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
name: "Ramipril",
} as never);
const res = await POST(
tgRequest({
update_id: 206,
@@ -767,8 +768,8 @@ describe("Telegram webhook — text-message dispatch", () => {
});
it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => {
// First findFirst (lookup by name) returns null
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
// The lookup by name finds nothing.
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never);
const res = await POST(
tgRequest({
@@ -912,6 +913,29 @@ describe("Telegram webhook — slot convergence (v1.16.9)", () => {
});
});
it("books the reminder's own dose on the last day of a course after it ended", async () => {
// The course ran through 9 June; yesterday's 09:00 reminder is confirmed
// the next morning. The dose belongs to a day the course covered.
wireMedicationFindFirst([scheduleRow], [], {
...MEDICATION,
dose: "5 mg",
startsOn: null,
endsOn: new Date("2026-06-09T00:00:00.000Z"),
endedAt: null,
});
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-09",
timeOfDay: "09:00",
} as never);
await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(prisma.$transaction).toHaveBeenCalled();
expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).not.toMatch(
/not found or inactive/i,
);
});
it("a take converges via band attribution when no reminder row matches", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
@@ -1224,7 +1248,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
it("the 'taken' keyword resolves tracked medications only", async () => {
await POST(tgRequest(textUpdate("taken Ramipril")));
const where = whereOf(vi.mocked(prisma.medication.findFirst).mock.calls[0]);
const where = whereOf(vi.mocked(prisma.medication.findMany).mock.calls[0]);
expect(where).toMatchObject({ active: true, trackIntake: true });
});
@@ -1232,7 +1256,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
await POST(tgRequest(callbackUpdate("taken:med-1")));
// The gate lookup: a record-only medication resolves to nothing here,
// so no dose is written (later lookups only bind an admitted dose).
const calls = vi.mocked(prisma.medication.findFirst).mock.calls;
const calls = vi.mocked(prisma.medication.findMany).mock.calls;
expect(calls.length).toBeGreaterThan(0);
expect(whereOf(calls[0])).toMatchObject({
id: "med-1",
@@ -77,12 +77,12 @@ describe("listTelegramDoseMedications", () => {
describe("findTelegramDoseMedication", () => {
it("resolves a current course", async () => {
db.findFirst.mockResolvedValue(CHRONIC);
db.findMany.mockResolvedValue([CHRONIC]);
const found = await findTelegramDoseMedication("user-1", null, {
id: "chronic",
});
expect(found?.id).toBe("chronic");
expect(db.findFirst).toHaveBeenCalledWith(
expect(db.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: "chronic",
@@ -95,7 +95,7 @@ describe("findTelegramDoseMedication", () => {
});
it("refuses an ended course", async () => {
db.findFirst.mockResolvedValue(ENDED_NOW);
db.findMany.mockResolvedValue([ENDED_NOW]);
expect(
await findTelegramDoseMedication("user-1", "Europe/Berlin", {
id: "ended-now",
@@ -103,11 +103,31 @@ describe("findTelegramDoseMedication", () => {
).toBeNull();
});
it("finds the current course beside an ended one of the same name", async () => {
db.findMany.mockResolvedValue([ENDED, CHRONIC]);
const found = await findTelegramDoseMedication("user-1", "Europe/Berlin", {
name: { equals: "ramipril", mode: "insensitive" },
});
expect(found?.id).toBe("chronic");
});
it("judges the course at the dose's own slot", async () => {
// The last evening dose, confirmed after midnight once the course ended.
db.findMany.mockResolvedValue([ENDED_NOW]);
const found = await findTelegramDoseMedication(
"user-1",
"Europe/Berlin",
{ id: "ended-now" },
new Date(Date.now() - 3 * 60 * 60 * 1000),
);
expect(found?.id).toBe("ended-now");
});
it("refuses everything with the Medications module off", async () => {
db.moduleOn.mockResolvedValue(false);
expect(
await findTelegramDoseMedication("user-1", null, { id: "chronic" }),
).toBeNull();
expect(db.findFirst).not.toHaveBeenCalled();
expect(db.findMany).not.toHaveBeenCalled();
});
});
+13 -5
View File
@@ -40,10 +40,10 @@ function courseEnded(
endedAt: Date | null;
},
timeZone: string | null,
at: Date = new Date(),
): boolean {
return (
courseStatusAt(medication, new Date(), timeZone || DEFAULT_TIMEZONE) ===
"ENDED"
courseStatusAt(medication, at, timeZone || DEFAULT_TIMEZONE) === "ENDED"
);
}
@@ -61,16 +61,24 @@ export async function listTelegramDoseMedications(
return medications.filter((m) => !courseEnded(m, timeZone));
}
/** The one medication `where` names, or null when the bot may not act on it. */
/**
* The medication `where` names, or null when the bot may not act on it. A
* name can match an ended course beside the current one, so the course is
* judged on every match. `at` is the moment the dose is for: a reminder's
* button books its own slot, so the last evening dose of a course can still
* be confirmed after midnight.
*/
export async function findTelegramDoseMedication(
userId: string,
timeZone: string | null,
where: Prisma.MedicationWhereInput,
at: Date = new Date(),
) {
if (!(await isModuleEnabled(userId, "medications"))) return null;
const medication = await prisma.medication.findFirst({
const medications = await prisma.medication.findMany({
where: { ...where, userId, active: true, ...TRACKED_INTAKE_WHERE },
select: DOSE_MEDICATION_SELECT,
orderBy: { id: "asc" },
});
return medication && !courseEnded(medication, timeZone) ? medication : null;
return medications.find((m) => !courseEnded(m, timeZone, at)) ?? null;
}
+19 -13
View File
@@ -266,9 +266,24 @@ async function markMedicationTaken(
reminderRef?: { chatId: string; messageId: number },
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
const { t } = getServerTranslator(locale);
const medication = await findTelegramDoseMedication(userId, userTz, {
id: medicationId,
});
const tz = userTz || DEFAULT_TIMEZONE;
// The reminder's own slot, when the message names one: the course is
// judged on that dose, not on the moment of the tap.
const reminderSlot = reminderRef
? await resolveReminderSlot({
userId,
medicationId,
userTz: tz,
chatId: reminderRef.chatId,
messageId: reminderRef.messageId,
})
: null;
const medication = await findTelegramDoseMedication(
userId,
userTz,
{ id: medicationId },
reminderSlot ?? undefined,
);
if (!medication) {
return { ok: false, message: t("telegram.errorMedicationInactive") };
}
@@ -281,7 +296,6 @@ async function markMedicationTaken(
let scheduledFor: Date | null = null;
if (!existing) {
const takenAt = new Date();
const tz = userTz || DEFAULT_TIMEZONE;
// v1.16.9 — converge onto the slot's canonical row instead of bare-
// creating a second row anchored at `now`. The old shape left the
@@ -289,15 +303,7 @@ async function markMedicationTaken(
// and compliance punished a dose the user explicitly confirmed.
// Prefer the reminder's own slot (the message names the dose it fired
// for); fall back to band attribution on the tap instant.
let canonicalSlot: Date | null = reminderRef
? await resolveReminderSlot({
userId,
medicationId: medication.id,
userTz: tz,
chatId: reminderRef.chatId,
messageId: reminderRef.messageId,
})
: null;
let canonicalSlot: Date | null = reminderSlot;
if (canonicalSlot === null) {
const attribution = await resolveSlotForWriteByBand({
userId,