mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(medications): silence a switched-off module and an ended course
With the Medications module switched off, dose reminders, missed-dose rows and low-stock alerts kept coming, a tap on one landed on "Medications is switched off", and the Telegram buttons still booked intakes. The reminder tick now drops the medications of an owner with the module off, one module read per owner and tick, so neither a reminder nor a missed-dose placeholder is written while it is off. The low-stock pass skips such a user, as the mood reminder does for its module, and the bot offers and books nothing. A medication whose course had ended still counted as live: the low-stock pass kept warning about its stock, and the bot's /add list offered it and booked a dose after the end, taking a unit off the stock. The low-stock pass and every bot path now skip a course that has ended on the person's clock. A course that has not started yet keeps its reorder warning and stays in the bot. A paused medication is already active: false, so the existing filter keeps it out. The bot's six copies of the medication lookup go through one helper.
This commit is contained in:
1 parent
8057bb20bc
commit
71fe874ef2
12 files changed
+451
-75
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": 1341,
|
||||
"lib/telegram-webhook-handlers.ts": 1313,
|
||||
"lib/validations/inbound-documents.ts": 1018,
|
||||
"lib/withings/sync-sleep.ts": 884,
|
||||
"lib/withings/sync.ts": 933,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = MEDICATION,
|
||||
) {
|
||||
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
|
||||
select?: Record<string, unknown>;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof getWorkerPrisma>) {
|
||||
return prisma.medication.findMany({
|
||||
async function loadRemindableMedications(
|
||||
prisma: ReturnType<typeof getWorkerPrisma>,
|
||||
) {
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
@@ -89,6 +97,17 @@ function loadRemindableMedications(prisma: ReturnType<typeof getWorkerPrisma>) {
|
||||
},
|
||||
},
|
||||
});
|
||||
const moduleOff = new Set<string>();
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user