diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index e6d9b833e..7705268f3 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": 1341, + "lib/telegram-webhook-handlers.ts": 1313, "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd83b4bdc..ec7212f25 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -339,8 +339,8 @@ "lib/support/report.ts :: collectSupportReportFacts": 332, "lib/targets/build-response.ts :: buildTargetsResponse": 330, "lib/targets/vitals-builder.ts :: buildVitalTargets": 324, - "lib/telegram-webhook-handlers.ts :: handleCallback": 599, - "lib/telegram-webhook-handlers.ts :: handleTextMessage": 337, + "lib/telegram-webhook-handlers.ts :: handleCallback": 582, + "lib/telegram-webhook-handlers.ts :: handleTextMessage": 322, "lib/withings/sync-activity.ts :: syncUserActivity": 377, "lib/withings/sync-ecg.ts :: syncUserEcg": 202, "lib/withings/sync-sleep.ts :: syncUserSleep": 414, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index dd511fc2a..22e2f5bfa 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -2333,7 +2333,7 @@ "lib/jobs/med-notes-encryption-backfill.ts": 2, "lib/jobs/medication-intake-import.ts": 1, "lib/jobs/medication-inventory-expire.ts": 1, - "lib/jobs/medication-low-stock.ts": 13, + "lib/jobs/medication-low-stock.ts": 12, "lib/jobs/mood-reminder.ts": 4, "lib/jobs/note-encryption-backfill.ts": 2, "lib/jobs/oidc-handoff-cleanup.ts": 1, @@ -2786,7 +2786,7 @@ "lib/targets/types.ts": 2, "lib/targets/vitals-builder.ts": 2, "lib/telegram-cleanup.ts": 1, - "lib/telegram-webhook-handlers.ts": 17, + "lib/telegram-webhook-handlers.ts": 16, "lib/telegram.ts": 2, "lib/time-window-format.ts": 1, "lib/timeline/__tests__/lane-access.test.ts": 1, diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index d0dd4e058..4781f80c2 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -97,6 +97,8 @@ vi.mock("@/lib/medications/dose-day-refresh", () => ({ refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); + import { POST, GET } from "../route"; import { prisma } from "@/lib/db"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -112,6 +114,7 @@ import { } from "@/lib/mood/create-from-telegram"; import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { isModuleEnabled } from "@/lib/modules/gate"; const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET; @@ -134,6 +137,7 @@ const MEDICATION = { id: "med-1", name: "Ramipril" }; function wireMedicationFindFirst( schedules: unknown[] = [], scheduleRevisions: unknown[] = [], + medication: Record = MEDICATION, ) { vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { select?: Record; @@ -149,7 +153,7 @@ function wireMedicationFindFirst( scheduleRevisions, }); } - return Promise.resolve(MEDICATION); + return Promise.resolve(medication); }) as never); } @@ -162,6 +166,7 @@ beforeEach(() => { resetAt: Date.now() + 60_000, } as never); 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); @@ -1236,3 +1241,86 @@ describe("POST /api/telegram/webhook — record-only medications", () => { }); }); }); + +/** + * With the Medications module switched off the bot offers no dose and books + * none: the web shows no medication surface and the reminder jobs are + * silent, so a reminder button left in the chat, the /add list and the + * "taken" keyword do nothing either. + */ +describe("POST /api/telegram/webhook — Medications module off", () => { + function textUpdate(text: string) { + return { + update_id: 400, + message: { message_id: 40, text, chat: { id: 7777 }, from: { id: 7777 } }, + }; + } + + beforeEach(() => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + }); + + it.each(["taken:med-1", "skip:med-1", "add:med-1"])( + "the %s button books nothing", + async (data) => { + const res = await POST(tgRequest(callbackUpdate(data))); + expect(res.status).toBe(200); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }, + ); + + it("the snooze button snoozes nothing", async () => { + await POST(tgRequest(callbackUpdate("snooze:med-1:60"))); + expect(prisma.medication.update).not.toHaveBeenCalled(); + expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch( + /not found/i, + ); + }); + + it("/add offers nothing", async () => { + await POST(tgRequest(textUpdate("/add"))); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch( + /no active medications/i, + ); + }); + + it("the 'taken' keyword records nothing", async () => { + await POST(tgRequest(textUpdate("taken Ramipril"))); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); + +/** + * A reminder button left in the chat takes no dose for a course that has + * since ended, which would also take a unit off the stock. The list side is + * pinned in `src/lib/__tests__/telegram-dose-medications.test.ts`. + */ +describe("POST /api/telegram/webhook — ended course", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const ENDED = { + ...MEDICATION, + dose: "5 mg", + startsOn: new Date(Date.now() - 20 * DAY_MS), + endsOn: new Date(Date.now() - 3 * DAY_MS), + endedAt: null, + }; + it("the taken button on an ended course books nothing", async () => { + wireMedicationFindFirst([], [], ENDED); + await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch( + /not found or inactive/i, + ); + }); + + it("the skip button on an ended course books nothing", async () => { + wireMedicationFindFirst([], [], ENDED); + await POST(tgRequest(callbackUpdate("skip:med-1"))); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/__tests__/telegram-dose-medications.test.ts b/src/lib/__tests__/telegram-dose-medications.test.ts new file mode 100644 index 000000000..a8fceac86 --- /dev/null +++ b/src/lib/__tests__/telegram-dose-medications.test.ts @@ -0,0 +1,113 @@ +/** + * Which medications the Telegram bot may offer a dose for or record one + * against: tracked and active, with the Medications module on, and not past + * the end of the course on the person's clock. A course that has not started + * yet stays. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + findMany: vi.fn(), + findFirst: vi.fn(), + moduleOn: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: db.findMany, findFirst: db.findFirst }, + }, +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: db.moduleOn })); + +import { + findTelegramDoseMedication, + listTelegramDoseMedications, +} from "@/lib/telegram-dose-medications"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const day = (offset: number) => new Date(Date.now() + offset * DAY_MS); + +const CHRONIC = { + id: "chronic", + name: "Ramipril", + dose: "5 mg", + startsOn: null, + endsOn: null, + endedAt: null, +}; +const ENDED = { + ...CHRONIC, + id: "ended", + startsOn: day(-12), + endsOn: day(-2), +}; +const ENDED_NOW = { + ...CHRONIC, + id: "ended-now", + startsOn: day(-12), + endsOn: day(0), + endedAt: new Date(Date.now() - 60_000), +}; +const UPCOMING = { ...CHRONIC, id: "upcoming", startsOn: day(3) }; + +beforeEach(() => { + vi.clearAllMocks(); + db.moduleOn.mockResolvedValue(true); +}); + +describe("listTelegramDoseMedications", () => { + it("leaves an ended course out and keeps a current or upcoming one", async () => { + db.findMany.mockResolvedValue([CHRONIC, ENDED, ENDED_NOW, UPCOMING]); + const listed = await listTelegramDoseMedications("user-1", "Europe/Berlin"); + expect(listed.map((m) => m.id)).toEqual(["chronic", "upcoming"]); + expect(db.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", active: true, trackIntake: true }, + }), + ); + }); + + it("offers nothing with the Medications module off", async () => { + db.moduleOn.mockResolvedValue(false); + expect(await listTelegramDoseMedications("user-1", null)).toEqual([]); + expect(db.moduleOn).toHaveBeenCalledWith("user-1", "medications"); + expect(db.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("findTelegramDoseMedication", () => { + it("resolves a current course", async () => { + db.findFirst.mockResolvedValue(CHRONIC); + const found = await findTelegramDoseMedication("user-1", null, { + id: "chronic", + }); + expect(found?.id).toBe("chronic"); + expect(db.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: "chronic", + userId: "user-1", + active: true, + trackIntake: true, + }, + }), + ); + }); + + it("refuses an ended course", async () => { + db.findFirst.mockResolvedValue(ENDED_NOW); + expect( + await findTelegramDoseMedication("user-1", "Europe/Berlin", { + id: "ended-now", + }), + ).toBeNull(); + }); + + 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(); + }); +}); diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts index 5aa3d9b6c..7e0f6b33e 100644 --- a/src/lib/jobs/__tests__/medication-low-stock.test.ts +++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts @@ -7,7 +7,10 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); + import type { PrismaClient } from "@/generated/prisma/client"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { buildLowStockPayload, decideLowStockAction, @@ -280,6 +283,7 @@ describe("runMedicationLowStockTick", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(isModuleEnabled).mockResolvedValue(true); }); it("notifies a below-threshold medication and stamps the crossing", async () => { @@ -426,6 +430,56 @@ describe("runMedicationLowStockTick", () => { expect(dispatch).not.toHaveBeenCalled(); }); + it("the Medications module switched off skips the user without reading medications", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + const dispatch = okDispatch(); + const prisma = prismaMock({ + users: [userRow()], + medications: [medRow()], + }); + + const summary = await runMedicationLowStockTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications"); + expect(summary.skippedModuleDisabled).toBe(1); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("skips a medication whose course has ended, keeps one not yet started", async () => { + const dispatch = okDispatch(); + const prisma = prismaMock({ + users: [{ ...userRow(), timezone: "Europe/Berlin" }], + medications: [ + medRow({ id: "med-ended", endsOn: daysAgo(3), startsOn: daysAgo(13) }), + medRow({ id: "med-ended-today", endsOn: now, endedAt: daysAgo(0.01) }), + medRow({ + id: "med-upcoming", + startsOn: new Date(now.getTime() + 3 * MS_PER_DAY), + }), + ], + }); + + const summary = await runMedicationLowStockTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + + expect(summary.skippedCourseEnded).toBe(2); + expect(summary.notified).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ medicationId: "med-upcoming" }), + }), + ); + }); + it("a schedule-less medication never notifies (no runway derivable)", async () => { const dispatch = okDispatch(); const prisma = prismaMock({ diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 5126e9765..a47225f06 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -99,6 +99,9 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(true), +})); vi.mock("@/lib/notifications/reminder-dedup", () => ({ claimNotificationEvent: vi.fn().mockResolvedValue(true), medicationReminderDedupKey: vi.fn(() => "dedup-key"), @@ -110,6 +113,7 @@ import { nextSlotHeldBySnooze, } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { isModuleEnabled } from "@/lib/modules/gate"; function medicationWithSchedule( schedule: Record, @@ -860,6 +864,26 @@ describe("issue #664 — exact occurrence identity survives local midnight", () ); }); + it("with the Medications module off, neither reminds nor mints the missed row", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T02:30:00.000Z")); // 04:30 Jul 29 + const medication = crossMidnightMedication("Europe/Berlin"); + prismaMock.medication.findMany.mockResolvedValue([ + medication, + { ...medication, id: "medication-2" }, + ]); + setIntakeEvents([]); + vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); + + await handleReminderCheck([]); + + // One read for the owner, not one per medication or slot. + expect(isModuleEnabled).toHaveBeenCalledTimes(1); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications"); + expect(prismaMock.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + it("does not mint a second missed row for a dose taken just before midnight", async () => { // The RED placeholder is the escalation's permanent trace: mint it for a // dose the user already logged and the medication reads as missed forever diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts index 75f250ab7..3b3ba5a49 100644 --- a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -124,6 +124,9 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(true), +})); import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 99b3fa8bf..16a8b2571 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -52,7 +52,11 @@ * follows the COACH_NUDGE posture (server-only, prefs-gated). * * Inactive / paused medications are skipped: nothing consumes their - * stock, so their runway is not falling. + * stock, so their runway is not falling. So is a medication whose course has + * ended on the person's clock; a course that has not started yet still warns, + * since its stock is about to be needed. And so is every medication of a + * person who switched the Medications module off, as the mood reminder does + * for the Mood module. * * As-needed (PRN) medications have no cadence and so no runway in days. * Once the runway math stopped counting a PRN schedule as a daily dose, @@ -86,6 +90,12 @@ import { coerceLocale, type Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { annotate, getEvent } from "@/lib/logging/context"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { + MEDICATION_WINDOW_SELECT, + courseStatusAt, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * An as-needed medication warns when this many doses or fewer are left: no @@ -93,9 +103,23 @@ import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; */ export const AS_NEEDED_LOW_STOCK_DOSES = 2; +/** + * A medication whose stock the pass watches: active, unpaused, with intake + * tracked (#1033, no dose consumes the stock of one kept as a record) and + * with tracked inventory (any container ever registered). + */ +const WATCHED_MEDICATION_WHERE = { + active: true, + pausedAt: null, + ...TRACKED_INTAKE_WHERE, + inventoryItems: { some: {} }, +}; + export interface MedicationLowStockSummary { usersScanned: number; skippedThresholdOff: number; + skippedModuleDisabled: number; + skippedCourseEnded: number; medicationsEvaluated: number; notified: number; rearmed: number; @@ -376,6 +400,8 @@ export async function runMedicationLowStockTick( const summary: MedicationLowStockSummary = { usersScanned: 0, skippedThresholdOff: 0, + skippedModuleDisabled: 0, + skippedCourseEnded: 0, medicationsEvaluated: 0, notified: 0, rearmed: 0, @@ -389,22 +415,10 @@ export async function runMedicationLowStockTick( const notifiedMeta: LowStockEventMeta[] = []; const skippedMeta: LowStockEventMeta[] = []; - // Only users who own at least one ACTIVE, unpaused medication with - // tracked inventory (any container ever registered) are candidates. + // Only users who own at least one watched medication are candidates. const users = await prisma.user.findMany({ - where: activeAccount({ - medications: { - some: { - active: true, - pausedAt: null, - // v1.39.1 (#1033) — no low-stock notice for a medication with - // intake tracking off: no dose consumes its stock. - ...TRACKED_INTAKE_WHERE, - inventoryItems: { some: {} }, - }, - }, - }), - select: { id: true, locale: true, notificationPrefs: true }, + where: activeAccount({ medications: { some: WATCHED_MEDICATION_WHERE } }), + select: { id: true, locale: true, timezone: true, notificationPrefs: true }, }); for (const user of users) { @@ -418,15 +432,15 @@ export async function runMedicationLowStockTick( summary.skippedThresholdOff += 1; continue; } + // After the threshold, so the module read runs only for users the + // alert is on for. + if (!(await isModuleEnabled(user.id, "medications"))) { + summary.skippedModuleDisabled += 1; + continue; + } const medications = await prisma.medication.findMany({ - where: { - userId: user.id, - active: true, - pausedAt: null, - ...TRACKED_INTAKE_WHERE, - inventoryItems: { some: {} }, - }, + where: { userId: user.id, ...WATCHED_MEDICATION_WHERE }, select: { id: true, name: true, @@ -436,6 +450,7 @@ export async function runMedicationLowStockTick( reorderLeadDays: true, lowStockNotifiedAt: true, lowStockNotifiedThresholdDays: true, + ...MEDICATION_WINDOW_SELECT, inventoryItems: { select: { state: true, unitsTotal: true, unitsRemaining: true }, }, @@ -458,7 +473,13 @@ export async function runMedicationLowStockTick( }, }); + const timeZone = user.timezone || DEFAULT_TIMEZONE; for (const med of medications) { + // Nothing consumes the stock of an ended course any more. + if (courseStatusAt(med, now, timeZone) === "ENDED") { + summary.skippedCourseEnded += 1; + continue; + } summary.medicationsEvaluated += 1; // Decimal → number for the per-slot units so the runway math and diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 2c9e85e5b..c7fa1dbc4 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -47,6 +47,8 @@ import { } from "@/lib/medications/scheduling/live-era"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { getEvent } from "@/lib/logging/context"; export interface ReminderCheckPayload { triggeredAt: string; @@ -65,9 +67,15 @@ const getUserTodayBounds = getUserTodayBoundsUtil; * a record, so it is excluded explicitly: no reminder on any channel and no * missed-dose placeholder (#1033). And none whose account is pending * deletion: that record is frozen until it is erased or given back. + * + * Nor any of a person who switched the Medications module off: no reminder + * and no missed-dose placeholder while it is off, as the mood reminder does + * for the Mood module. One module read per owner per tick. */ -function loadRemindableMedications(prisma: ReturnType) { - return prisma.medication.findMany({ +async function loadRemindableMedications( + prisma: ReturnType, +) { + const medications = await prisma.medication.findMany({ where: { active: true, asNeeded: false, @@ -89,6 +97,17 @@ function loadRemindableMedications(prisma: ReturnType) { }, }, }); + const moduleOff = new Set(); + for (const userId of new Set(medications.map((med) => med.user.id))) { + if (!(await isModuleEnabled(userId, "medications"))) moduleOff.add(userId); + } + if (moduleOff.size > 0) { + getEvent()?.addMeta( + "medication_reminder.skipped_module_disabled", + moduleOff.size, + ); + } + return medications.filter((med) => !moduleOff.has(med.user.id)); } /** diff --git a/src/lib/telegram-dose-medications.ts b/src/lib/telegram-dose-medications.ts new file mode 100644 index 000000000..7820d860c --- /dev/null +++ b/src/lib/telegram-dose-medications.ts @@ -0,0 +1,76 @@ +/** + * The medications the Telegram bot may offer a dose for or record one + * against: the `/add` list, the "taken" keyword, and the reminder buttons. + * + * Active and with intake tracked, as every bot path has asked since #1033, + * and two conditions the bot used to skip: + * + * - The Medications module is on. With it off the web shows no medication + * surface and the reminder jobs send nothing, so the bot offers and books + * nothing either; re-enabling the module finds every row where it was. + * - The course has not ended on the person's clock (`courseStatusAt`). The + * web card stops offering a dose then, and a dose booked through the bot + * after the end would take a unit off the stock. A course that has not + * started yet is left to the bot as before. + * + * A paused medication is `active: false` (pausing writes both), so the + * `active` filter already keeps it out. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + MEDICATION_WINDOW_SELECT, + courseStatusAt, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +const DOSE_MEDICATION_SELECT = { + id: true, + name: true, + dose: true, + ...MEDICATION_WINDOW_SELECT, +} as const; + +function courseEnded( + medication: { + startsOn: Date | null; + endsOn: Date | null; + endedAt: Date | null; + }, + timeZone: string | null, +): boolean { + return ( + courseStatusAt(medication, new Date(), timeZone || DEFAULT_TIMEZONE) === + "ENDED" + ); +} + +/** Every medication the person may pick a dose from, by name. */ +export async function listTelegramDoseMedications( + userId: string, + timeZone: string | null, +) { + if (!(await isModuleEnabled(userId, "medications"))) return []; + const medications = await prisma.medication.findMany({ + where: { userId, active: true, ...TRACKED_INTAKE_WHERE }, + select: DOSE_MEDICATION_SELECT, + orderBy: [{ name: "asc" }, { id: "asc" }], + }); + return medications.filter((m) => !courseEnded(m, timeZone)); +} + +/** The one medication `where` names, or null when the bot may not act on it. */ +export async function findTelegramDoseMedication( + userId: string, + timeZone: string | null, + where: Prisma.MedicationWhereInput, +) { + if (!(await isModuleEnabled(userId, "medications"))) return null; + const medication = await prisma.medication.findFirst({ + where: { ...where, userId, active: true, ...TRACKED_INTAKE_WHERE }, + select: DOSE_MEDICATION_SELECT, + }); + return medication && !courseEnded(medication, timeZone) ? medication : null; +} diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index b1f2e920e..c152bff52 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -37,10 +37,12 @@ import { isTelegramCapturableType, logTelegramMeasurement, } from "@/lib/measurements/create-from-telegram"; -// v1.39.1 (#1033) — every Telegram path that offers, records or snoozes a -// dose (the /add list, the "taken" keyword, the reminder buttons) leaves a -// medication kept as a record out, as the web and the today list do. -import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +// Every Telegram path that offers, records or snoozes a dose resolves the +// medication here: tracked, active, course not ended, Medications module on. +import { + findTelegramDoseMedication, + listTelegramDoseMedications, +} from "@/lib/telegram-dose-medications"; import { GREETING_WORDS, HELP_WORDS } from "@/lib/telegram-bare-words"; /** @@ -264,9 +266,8 @@ async function markMedicationTaken( reminderRef?: { chatId: string; messageId: number }, ): Promise<{ ok: boolean; message: string; medicationName?: string }> { const { t } = getServerTranslator(locale); - const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true }, + const medication = await findTelegramDoseMedication(userId, userTz, { + id: medicationId, }); if (!medication) { return { ok: false, message: t("telegram.errorMedicationInactive") }; @@ -481,15 +482,11 @@ export async function handleCallback(update: TelegramUpdate) { return; } - const medication = await prisma.medication.findFirst({ - where: { - id: medicationId, - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - }, - select: { id: true, name: true }, - }); + const medication = await findTelegramDoseMedication( + user.id, + user.timezone, + { id: medicationId }, + ); if (!medication) { await answerTelegramCallbackQuery( botToken, @@ -528,15 +525,11 @@ export async function handleCallback(update: TelegramUpdate) { return; } - const medication = await prisma.medication.findFirst({ - where: { - id: medicationId, - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - }, - select: { id: true, name: true }, - }); + const medication = await findTelegramDoseMedication( + user.id, + user.timezone, + { id: medicationId }, + ); if (!medication) { await answerTelegramCallbackQuery( botToken, @@ -1155,11 +1148,7 @@ export async function handleTextMessage(update: TelegramUpdate) { if (/^\/add\b/i.test(text)) { const userMsgId = message?.message_id; - const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true, dose: true }, - orderBy: [{ name: "asc" }, { id: "asc" }], - }); + const meds = await listTelegramDoseMedications(user.id, user.timezone); if (meds.length === 0) { const resp = await sendTelegramMessage( @@ -1260,23 +1249,12 @@ export async function handleTextMessage(update: TelegramUpdate) { let medicationId: string | null = null; if (nameInput) { - const med = await prisma.medication.findFirst({ - where: { - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - name: { equals: nameInput, mode: "insensitive" }, - }, - select: { id: true }, + const med = await findTelegramDoseMedication(user.id, user.timezone, { + name: { equals: nameInput, mode: "insensitive" }, }); medicationId = med?.id ?? null; } else { - const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true }, - orderBy: [{ name: "asc" }, { id: "asc" }], - take: 2, - }); + const meds = await listTelegramDoseMedications(user.id, user.timezone); if (meds.length === 1) { medicationId = meds[0].id; } else {