diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index e49138989..54d329d69 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -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, diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 4781f80c2..4226f6012 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -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; + }) => + 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", diff --git a/src/lib/__tests__/telegram-dose-medications.test.ts b/src/lib/__tests__/telegram-dose-medications.test.ts index a8fceac86..d0d0af78e 100644 --- a/src/lib/__tests__/telegram-dose-medications.test.ts +++ b/src/lib/__tests__/telegram-dose-medications.test.ts @@ -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(); }); }); diff --git a/src/lib/telegram-dose-medications.ts b/src/lib/telegram-dose-medications.ts index 7820d860c..b0eb87d43 100644 --- a/src/lib/telegram-dose-medications.ts +++ b/src/lib/telegram-dose-medications.ts @@ -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; } diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index c152bff52..7ea752ac5 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -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,