mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body
The external ingest endpoint was the one intake writer that never attributed to the minted band: it stored a bare row beside the pending slot, so an early take through the API showed as the last intake while the slot kept demanding a dose, the ledger recorded an extra plus a miss, and the day tally counted both. It now resolves the band and converges onto the pending row like every other path, the Telegram confirm and skip buttons do the same preferring the reminder's own slot, and the nightly convergence pass heals the pairs this has already produced — one band match, one live anchor row, both days' rollups recomputed. The cache layer fences in-flight builders on invalidation so a read can neither join a pre-write build nor have a background refresh re-cache a pre-write body as fresh. Ad-hoc rows no longer resolve a different slot through the six-hour radius, a bounded early grace credits a take up to an hour before the window without crossing the previous band, day-scale cadences taken early downgrade the slot-day prompt instead of inviting a double dose, the overdue pill ignores the projector's pending mints, created intakes carry the same plausibility bounds as edits, slot moves keep the recorded injection site and dose, both affected days recompute on a moved toggle, deliberate slot-less skips converge instead of orphaning, the coach reads adherence from the ledger, the cards and quick-add run in the user's timezone with the served next-due as the gate, and schedule-revision, wipe and restore writes invalidate the caches they feed.
This commit is contained in:
1 parent
ea29e9cf1b
commit
aaecd141ee
53 files changed
+2550
-202
No files matched your search
@@ -18,9 +18,12 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationIntakeEvent: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
telegramReminderMessage: {
|
||||
deleteMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
telegramScheduledDeletion: {
|
||||
createMany: vi.fn(),
|
||||
@@ -52,8 +55,17 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
answerTelegramCallbackQuery,
|
||||
deleteMessage,
|
||||
@@ -67,10 +79,40 @@ const TG_USER = {
|
||||
id: "user-1",
|
||||
telegramBotToken: "ENC(token-blob)",
|
||||
locale: "en",
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
const MEDICATION = { id: "med-1", name: "Ramipril" };
|
||||
|
||||
/**
|
||||
* The slot-attribution load (`loadAttributeMedication`) selects the full
|
||||
* 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.
|
||||
*/
|
||||
function wireMedicationFindFirst(
|
||||
schedules: unknown[] = [],
|
||||
scheduleRevisions: unknown[] = [],
|
||||
) {
|
||||
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
|
||||
select?: Record<string, unknown>;
|
||||
}) => {
|
||||
if (args?.select && "schedules" in args.select) {
|
||||
return Promise.resolve({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules,
|
||||
scheduleRevisions,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(MEDICATION);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.TELEGRAM_WEBHOOK_SECRET = "sekret";
|
||||
@@ -80,14 +122,18 @@ beforeEach(() => {
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never);
|
||||
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);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
@@ -185,15 +231,7 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// $transaction is invoked with [create-intake, update-medication]. Inspect
|
||||
// its argument list for both writes.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown;
|
||||
expect(Array.isArray(txOps)).toBe(true);
|
||||
|
||||
// The route calls prisma.medicationIntakeEvent.create + medication.update
|
||||
// synchronously while building the transaction array, so these mocks
|
||||
// record the arguments the way it built them.
|
||||
// No schedules wired → the take is ad-hoc and inserts standalone.
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
@@ -226,7 +264,7 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
null,
|
||||
);
|
||||
await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call: existing-row lookup hits → no second write.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
|
||||
@@ -234,7 +272,7 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
} as never);
|
||||
const res2 = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res2.status).toBe(200);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // unchanged
|
||||
});
|
||||
|
||||
it("'snooze:<medId>:60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => {
|
||||
@@ -599,6 +637,159 @@ describe("Telegram webhook — text-message dispatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — slot convergence (v1.16.9)", () => {
|
||||
// Daily 09:00 Berlin med; the worker pre-minted the pending REMINDER row
|
||||
// at the canonical slot instant. A Telegram take/skip must converge onto
|
||||
// that row instead of inserting a second row anchored at `now` — the old
|
||||
// shape left the pending row open to auto-miss, punishing compliance for
|
||||
// a dose the user explicitly confirmed.
|
||||
const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST
|
||||
const NOW_0903_BERLIN = new Date("2026-06-10T07:03:00.000Z"); // 09:03 CEST
|
||||
|
||||
const scheduleRow = {
|
||||
id: "sched-1",
|
||||
windowStart: "09:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
const pendingReminderRow = {
|
||||
id: "evt-pending-0900",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: SLOT_0900_BERLIN,
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-10T05:00:00.000Z"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW_0903_BERLIN);
|
||||
wireMedicationFindFirst([scheduleRow]);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
pendingReminderRow,
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: {
|
||||
data: Record<string, unknown>;
|
||||
}) =>
|
||||
Promise.resolve({
|
||||
...pendingReminderRow,
|
||||
...args.data,
|
||||
syncVersion: 2,
|
||||
})) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("a take converges onto the pending REMINDER row via the reminder's own slot", async () => {
|
||||
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
|
||||
date: "2026-06-10",
|
||||
timeOfDay: "09:00",
|
||||
} as never);
|
||||
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The pending row was updated in place — never a duplicate insert.
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
|
||||
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
|
||||
.calls[0][0] as {
|
||||
where: { id: string };
|
||||
data: { takenAt: Date; skipped: boolean; autoMissed?: boolean };
|
||||
};
|
||||
expect(update.where.id).toBe("evt-pending-0900");
|
||||
expect(update.data.takenAt?.getTime()).toBe(NOW_0903_BERLIN.getTime());
|
||||
expect(update.data.skipped).toBe(false);
|
||||
expect(update.data.autoMissed).toBe(false);
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
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")));
|
||||
expect(res.status).toBe(200);
|
||||
// 09:03 sits inside the 09:00 band → same convergence, no second row.
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mock.calls[0][0] as {
|
||||
where: { id: string };
|
||||
}
|
||||
).where.id,
|
||||
).toBe("evt-pending-0900");
|
||||
});
|
||||
|
||||
it("a skip converges onto the pending REMINDER row as skipped (no orphan + no miss)", async () => {
|
||||
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
|
||||
date: "2026-06-10",
|
||||
timeOfDay: "09:00",
|
||||
} as never);
|
||||
|
||||
const res = await POST(tgRequest(callbackUpdate("skip:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
|
||||
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
|
||||
.calls[0][0] as {
|
||||
where: { id: string };
|
||||
data: { takenAt: Date | null; skipped: boolean };
|
||||
};
|
||||
expect(update.where.id).toBe("evt-pending-0900");
|
||||
expect(update.data.takenAt).toBeNull();
|
||||
expect(update.data.skipped).toBe(true);
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("an exotic-zone reminder slot reconstructs on the correct local day", async () => {
|
||||
// Pacific/Kiritimati (UTC+14): 09:00 on 2026-06-10 local is
|
||||
// 2026-06-09T19:00Z — a naive UTC-noon reference would land a day off.
|
||||
const slotKiritimati = new Date("2026-06-09T19:00:00.000Z");
|
||||
const nowKiritimati = new Date("2026-06-09T19:05:00.000Z");
|
||||
vi.setSystemTime(nowKiritimati);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...TG_USER,
|
||||
timezone: "Pacific/Kiritimati",
|
||||
} as never);
|
||||
const pendingRow = {
|
||||
...pendingReminderRow,
|
||||
scheduledFor: slotKiritimati,
|
||||
};
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
pendingRow,
|
||||
] as never);
|
||||
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
|
||||
date: "2026-06-10",
|
||||
timeOfDay: "09:00",
|
||||
} as never);
|
||||
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
|
||||
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
|
||||
.calls[0][0] as { where: { id: string } };
|
||||
expect(update.where.id).toBe("evt-pending-0900");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — GET verification", () => {
|
||||
it("returns 200 with a valid header secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
|
||||
@@ -14,6 +14,15 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id: number;
|
||||
@@ -123,12 +132,88 @@ async function findTelegramUser(chatId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.9 — the slot a Telegram action should converge onto.
|
||||
*
|
||||
* The reminder context knows its own slot: the dispatcher records every
|
||||
* reminder message as a `TelegramReminderMessage` row carrying the
|
||||
* local `date` + `timeOfDay` of the dose it fired for. When the action
|
||||
* callback carries the chat/message ids, that row names the EXACT slot
|
||||
* — preferred over band attribution, which would orphan a confirmation
|
||||
* tapped hours after the reminder (band closed → ad-hoc) even though
|
||||
* the user is answering a specific dose prompt.
|
||||
*
|
||||
* Returns the canonical band anchor (validated against the schedule's
|
||||
* real slots), or `null` when no reminder row matches / the instant is
|
||||
* not a real slot — the caller then falls back to band attribution.
|
||||
*/
|
||||
async function resolveReminderSlot(input: {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
userTz: string;
|
||||
chatId: string;
|
||||
messageId: number;
|
||||
}): Promise<Date | null> {
|
||||
const reminder = await prisma.telegramReminderMessage.findFirst({
|
||||
where: {
|
||||
medicationId: input.medicationId,
|
||||
chatId: input.chatId,
|
||||
messageId: input.messageId,
|
||||
},
|
||||
select: { date: true, timeOfDay: true },
|
||||
});
|
||||
if (!reminder) return null;
|
||||
const instant = reminderLocalInstant(
|
||||
reminder.date,
|
||||
reminder.timeOfDay,
|
||||
input.userTz,
|
||||
);
|
||||
if (!instant) return null;
|
||||
// Validate against the medication's real band anchors so a stale or
|
||||
// malformed reminder row can never park a dose on a non-slot instant.
|
||||
return resolveForcedSlotForWrite({
|
||||
userId: input.userId,
|
||||
medicationId: input.medicationId,
|
||||
userTz: input.userTz,
|
||||
slotInstant: instant,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the UTC instant of local `timeOfDay` on the local calendar day
|
||||
* `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either
|
||||
* side of UTC noon so the result is correct in every zone offset, then
|
||||
* derives the DST-correct instant via `localHmAsUtc`.
|
||||
*/
|
||||
function reminderLocalInstant(
|
||||
date: string,
|
||||
timeOfDay: string,
|
||||
tz: string,
|
||||
): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) {
|
||||
return null;
|
||||
}
|
||||
const [hour, minute] = timeOfDay.split(":").map(Number);
|
||||
const base = new Date(`${date}T12:00:00.000Z`);
|
||||
if (Number.isNaN(base.getTime())) return null;
|
||||
for (const shiftDays of [0, -1, 1]) {
|
||||
const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000);
|
||||
const parts = wallClockInTz(ref, tz);
|
||||
const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(
|
||||
parts.day,
|
||||
).padStart(2, "0")}`;
|
||||
if (localDay === date) return localHmAsUtc(ref, tz, hour, minute);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function markMedicationTaken(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
idempotencyKey: string,
|
||||
locale: Locale,
|
||||
userTz: string | null,
|
||||
reminderRef?: { chatId: string; messageId: number },
|
||||
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const medication = await prisma.medication.findFirst({
|
||||
@@ -146,29 +231,77 @@ async function markMedicationTaken(
|
||||
|
||||
let scheduledFor: Date | null = null;
|
||||
if (!existing) {
|
||||
scheduledFor = new Date();
|
||||
await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
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
|
||||
// worker-minted pending REMINDER row open: the slot later auto-missed
|
||||
// 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;
|
||||
if (canonicalSlot === null) {
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId,
|
||||
medicationId: medication.id,
|
||||
userTz: tz,
|
||||
takenAt,
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
}
|
||||
|
||||
if (canonicalSlot) {
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId,
|
||||
medicationId: medication.id,
|
||||
canonicalSlot,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey,
|
||||
createSource: "REMINDER",
|
||||
attributionSource: "AUTO",
|
||||
});
|
||||
scheduledFor = applied.row.scheduledFor;
|
||||
} else {
|
||||
// Ad-hoc / PRN — standalone row under the documented contract
|
||||
// (`scheduledFor = takenAt`).
|
||||
const created = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
medicationId: medication.id,
|
||||
scheduledFor,
|
||||
takenAt: scheduledFor,
|
||||
scheduledFor: takenAt,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
idempotencyKey,
|
||||
},
|
||||
}),
|
||||
prisma.medication.update({
|
||||
where: { id: medication.id },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
scheduledFor = created.scheduledFor;
|
||||
}
|
||||
await prisma.medication.update({
|
||||
where: { id: medication.id },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
// The confirmation must reflect on the next read (today feed, card
|
||||
// pill, compliance) rather than wait out the cache TTL.
|
||||
invalidateUserMedications(userId, { evict: true });
|
||||
}
|
||||
|
||||
// v1.4.39 W-MED — refresh the compliance rollup for the
|
||||
// affected day so the next read after the cache miss reflects the
|
||||
// new dose. Only fires when the create-path landed a new row.
|
||||
// new dose. Only fires when the write landed a row.
|
||||
if (scheduledFor) {
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
userId,
|
||||
@@ -225,6 +358,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
idempotencyKey,
|
||||
locale,
|
||||
user.timezone,
|
||||
messageId !== undefined ? { chatId, messageId } : undefined,
|
||||
);
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
|
||||
if (messageId) {
|
||||
@@ -313,25 +447,68 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
if (!existing) {
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
skippedScheduledFor = new Date();
|
||||
const skipMoment = new Date();
|
||||
const tz = user.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
// v1.16.9 — a Telegram skip answers a specific dose prompt, so it
|
||||
// must converge onto the slot's pending row like a take does. The
|
||||
// old shape inserted a second row anchored at `now` and left the
|
||||
// pending REMINDER row open to auto-miss — the user's deliberate
|
||||
// skip recorded as a miss AND an orphan. Prefer the reminder's own
|
||||
// slot; fall back to the band the skip moment falls in.
|
||||
let canonicalSlot: Date | null =
|
||||
messageId !== undefined
|
||||
? await resolveReminderSlot({
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
userTz: tz,
|
||||
chatId,
|
||||
messageId,
|
||||
})
|
||||
: null;
|
||||
if (canonicalSlot === null) {
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
userTz: tz,
|
||||
takenAt: skipMoment,
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
}
|
||||
|
||||
if (canonicalSlot) {
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
canonicalSlot,
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
isExplicitTaken: false,
|
||||
isExplicitSkip: true,
|
||||
idempotencyKey,
|
||||
createSource: "REMINDER",
|
||||
});
|
||||
skippedScheduledFor = applied.row.scheduledFor;
|
||||
} else {
|
||||
const created = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: skippedScheduledFor,
|
||||
scheduledFor: skipMoment,
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
source: "REMINDER",
|
||||
idempotencyKey,
|
||||
},
|
||||
}),
|
||||
prisma.medication.update({
|
||||
where: { id: medication.id },
|
||||
data: { snoozedUntil: endOfDay },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
skippedScheduledFor = created.scheduledFor;
|
||||
}
|
||||
await prisma.medication.update({
|
||||
where: { id: medication.id },
|
||||
data: { snoozedUntil: endOfDay },
|
||||
});
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
// v1.4.39 W-MED — refresh the compliance rollup for the skipped
|
||||
|
||||
Reference in new issue
Block a user