feat: scope Telegram reminder tracking to recipients

- keep delegated recipient slots independent\n- bind callback cleanup to the recipient account
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 04:38:09 +02:00
1 parent 5807879439
commit fa46f27b01
6 files changed
+99 -51

No files matched your search

@@ -50,6 +50,34 @@ CREATE TABLE "notification_events" (
CREATE INDEX "notification_events_record_user_id_event_type_dedup_key_created_at_desc_idx" CREATE INDEX "notification_events_record_user_id_event_type_dedup_key_created_at_desc_idx"
ON "notification_events" ("record_user_id", "event_type", "dedup_key", "created_at" DESC); ON "notification_events" ("record_user_id", "event_type", "dedup_key", "created_at" DESC);
-- Tracking belongs to the account that received the Telegram message. The
-- prior subject-only key collapsed multiple delegated recipients into one row.
ALTER TABLE "telegram_reminder_messages"
ADD COLUMN "recipient_user_id" TEXT;
UPDATE "telegram_reminder_messages" AS "message"
SET "recipient_user_id" = "medication"."user_id"
FROM "medications" AS "medication"
WHERE "medication"."id" = "message"."medication_id";
ALTER TABLE "telegram_reminder_messages"
ALTER COLUMN "recipient_user_id" SET NOT NULL,
ADD CONSTRAINT "telegram_reminder_messages_recipient_user_id_fkey"
FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
DROP INDEX "telegram_reminder_messages_med_sched_date_phase_tod_key";
CREATE UNIQUE INDEX "telegram_reminder_messages_recipient_slot_key"
ON "telegram_reminder_messages" (
"recipient_user_id",
"medication_id",
"schedule_id",
"date",
"phase",
"time_of_day"
);
CREATE FUNCTION enforce_push_attempt_attribution() CREATE FUNCTION enforce_push_attempt_attribution()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
+15 -12
View File
@@ -863,6 +863,7 @@ model User {
pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser") pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser")
pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser") pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser")
notificationEvents NotificationEvent[] notificationEvents NotificationEvent[]
telegramReminderMessages TelegramReminderMessage[]
// v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
// TRIMP + running EWMA reference) so the nightly Strain engine anchors // TRIMP + running EWMA reference) so the nightly Strain engine anchors
// the 0–100 map to the user's own training load without re-integrating // the 0–100 map to the user's own training load without re-integrating
@@ -3364,24 +3365,26 @@ model ReminderPhaseConfig {
} }
model TelegramReminderMessage { model TelegramReminderMessage {
id String @id @default(cuid()) id String @id @default(cuid())
medicationId String @map("medication_id") recipientUserId String @map("recipient_user_id")
scheduleId String @map("schedule_id") medicationId String @map("medication_id")
chatId String @map("chat_id") scheduleId String @map("schedule_id")
messageId Int @map("message_id") chatId String @map("chat_id")
phase ReminderPhase messageId Int @map("message_id")
sentAt DateTime @default(now()) @map("sent_at") phase ReminderPhase
date String // YYYY-MM-DD for day grouping sentAt DateTime @default(now()) @map("sent_at")
date String // YYYY-MM-DD for day grouping
/// v1.7.0 — time-of-day this reminder belongs to (HH:mm, user-local). /// v1.7.0 — time-of-day this reminder belongs to (HH:mm, user-local).
/// Empty string = the single legacy window (pre-v1.7 rows + single-time /// Empty string = the single legacy window (pre-v1.7 rows + single-time
/// schedules). Part of the dedup key so a multi-time schedule dispatches /// schedules). Part of the dedup key so a multi-time schedule dispatches
/// once per time-of-day per phase per day instead of once per day. /// once per time-of-day per phase per day instead of once per day.
timeOfDay String @default("") @map("time_of_day") timeOfDay String @default("") @map("time_of_day")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) recipientUser User @relation(fields: [recipientUserId], references: [id], onDelete: Cascade)
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@unique([medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_med_sched_date_phase_tod_key") @@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key")
@@index([medicationId, date]) @@index([medicationId, date])
@@map("telegram_reminder_messages") @@map("telegram_reminder_messages")
} }
@@ -291,6 +291,14 @@ describe("Telegram webhook — callback dispatch", () => {
const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(prisma.telegramReminderMessage.deleteMany).toHaveBeenCalledWith({
where: {
recipientUserId: "user-1",
medicationId: "med-1",
medication: { userId: "user-1" },
},
});
// No schedules wired → the take is ad-hoc and inserts standalone. // No schedules wired → the take is ad-hoc and inserts standalone.
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
@@ -779,6 +787,16 @@ describe("Telegram webhook — slot convergence (v1.16.9)", () => {
const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(prisma.telegramReminderMessage.findFirst).toHaveBeenCalledWith({
where: {
recipientUserId: "user-1",
medicationId: "med-1",
chatId: "7777",
messageId: 555,
},
select: { date: true, timeOfDay: true },
});
// The pending row was updated in place — never a duplicate insert. // The pending row was updated in place — never a duplicate insert.
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
@@ -15,7 +15,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sendTelegramMessageMock = vi.fn(); const sendTelegramMessageMock = vi.fn();
const deleteMessageMock = vi.fn(); const deleteMessageMock = vi.fn();
const findUniqueMock = vi.fn(); const findUniqueMock = vi.fn();
const deleteMock = vi.fn();
const upsertMock = vi.fn(); const upsertMock = vi.fn();
const userFindUniqueMock = vi.fn(); const userFindUniqueMock = vi.fn();
@@ -31,7 +30,6 @@ vi.mock("@/lib/db", () => ({
}, },
telegramReminderMessage: { telegramReminderMessage: {
findUnique: (...args: unknown[]) => findUniqueMock(...args), findUnique: (...args: unknown[]) => findUniqueMock(...args),
delete: (...args: unknown[]) => deleteMock(...args),
upsert: (...args: unknown[]) => upsertMock(...args), upsert: (...args: unknown[]) => upsertMock(...args),
}, },
}, },
@@ -79,7 +77,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 999 }); sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 999 });
deleteMessageMock.mockResolvedValue(undefined); deleteMessageMock.mockResolvedValue(undefined);
findUniqueMock.mockResolvedValue(null); findUniqueMock.mockResolvedValue(null);
deleteMock.mockResolvedValue(undefined);
upsertMock.mockResolvedValue(undefined); upsertMock.mockResolvedValue(undefined);
userFindUniqueMock.mockResolvedValue({ locale: "en" }); userFindUniqueMock.mockResolvedValue({ locale: "en" });
scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined); scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined);
@@ -107,7 +104,7 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
}); });
}); });
it("deletes only the matching slot's ledger row, leaving sibling slots untouched", async () => { it("replaces only the matching slot's remote message, leaving its tracking row for update", async () => {
// The 20:00 slot has a prior row; the lookup returns it. // The 20:00 slot has a prior row; the lookup returns it.
findUniqueMock.mockResolvedValue({ findUniqueMock.mockResolvedValue({
chatId: "chat-1", chatId: "chat-1",
@@ -118,20 +115,22 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
// The Telegram message for the old 20:00 row is deleted... // The Telegram message for the old 20:00 row is deleted...
expect(deleteMessageMock).toHaveBeenCalledWith("bot-token", "chat-1", 123); expect(deleteMessageMock).toHaveBeenCalledWith("bot-token", "chat-1", 123);
// ...and exactly that single ledger row is removed (scoped delete, not deleteMany). // The tracking row stays in place so the recipient-scoped upsert updates
expect(deleteMock).toHaveBeenCalledTimes(1); // it rather than creating a second row when the chat binding changes.
expect(deleteMock).toHaveBeenCalledWith({ expect(upsertMock).toHaveBeenCalledWith(
where: { expect.objectContaining({
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { where: {
recipientUserId: "user-1", recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
medicationId: "med-1", recipientUserId: "user-1",
scheduleId: "sched-1", medicationId: "med-1",
date: "2025-06-10", scheduleId: "sched-1",
phase: "YELLOW", date: "2025-06-10",
timeOfDay: "20:00", phase: "YELLOW",
timeOfDay: "20:00",
},
}, },
}, }),
}); );
}); });
it("does not delete anything when no prior row exists for the slot", async () => { it("does not delete anything when no prior row exists for the slot", async () => {
@@ -140,7 +139,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
await sendViaTelegram(config, reminderPayload()); await sendViaTelegram(config, reminderPayload());
expect(deleteMessageMock).not.toHaveBeenCalled(); expect(deleteMessageMock).not.toHaveBeenCalled();
expect(deleteMock).not.toHaveBeenCalled();
// It still sends + tracks the new message. // It still sends + tracks the new message.
expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1); expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1);
expect(upsertMock).toHaveBeenCalledTimes(1); expect(upsertMock).toHaveBeenCalledTimes(1);
@@ -155,7 +153,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
); );
expect(findUniqueMock).not.toHaveBeenCalled(); expect(findUniqueMock).not.toHaveBeenCalled();
expect(deleteMock).not.toHaveBeenCalled();
}); });
it("keeps two recipients' identical subject slots independent", async () => { it("keeps two recipients' identical subject slots independent", async () => {
+8 -14
View File
@@ -52,7 +52,7 @@ export interface TelegramSendResult extends SendOutcome {
/** /**
* Delete the existing Telegram reminder message for a single dose slot * Delete the existing Telegram reminder message for a single dose slot
* before re-sending it. v1.7.0 SB-SCHED-4 — scoped to the exact slot * before re-sending it. v1.7.0 SB-SCHED-4 — scoped to the exact slot
* `{ medicationId, scheduleId, date, phase, timeOfDay }` (the same * `{ recipientUserId, medicationId, scheduleId, date, phase, timeOfDay }` (the same
* composite the tracking upsert keys on), NOT the whole medication. A * composite the tracking upsert keys on), NOT the whole medication. A
* medication with `timesOfDay = ["08:00","20:00"]` has two distinct * medication with `timesOfDay = ["08:00","20:00"]` has two distinct
* ledger rows per day; the pre-v1.7 whole-medication wipe deleted the * ledger rows per day; the pre-v1.7 whole-medication wipe deleted the
@@ -62,6 +62,7 @@ export interface TelegramSendResult extends SendOutcome {
*/ */
async function deleteExistingReminders( async function deleteExistingReminders(
botToken: string, botToken: string,
recipientUserId: string,
medicationId: string, medicationId: string,
scheduleId: string, scheduleId: string,
date: string, date: string,
@@ -71,7 +72,8 @@ async function deleteExistingReminders(
try { try {
const existing = await prisma.telegramReminderMessage.findUnique({ const existing = await prisma.telegramReminderMessage.findUnique({
where: { where: {
medicationId_scheduleId_date_phase_timeOfDay: { recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId,
medicationId, medicationId,
scheduleId, scheduleId,
date, date,
@@ -89,17 +91,6 @@ async function deleteExistingReminders(
// Best-effort: message may already be deleted // Best-effort: message may already be deleted
} }
await prisma.telegramReminderMessage.delete({
where: {
medicationId_scheduleId_date_phase_timeOfDay: {
medicationId,
scheduleId,
date,
phase,
timeOfDay,
},
},
});
} catch (err) { } catch (err) {
getEvent()?.addWarning(`Failed to delete existing reminders: ${err}`); getEvent()?.addWarning(`Failed to delete existing reminders: ${err}`);
} }
@@ -136,6 +127,7 @@ export async function sendViaTelegram(
if (medicationId && scheduleId && phase && date) { if (medicationId && scheduleId && phase && date) {
await deleteExistingReminders( await deleteExistingReminders(
config.botToken, config.botToken,
userId,
medicationId, medicationId,
scheduleId, scheduleId,
date, date,
@@ -220,7 +212,8 @@ export async function sendViaTelegram(
try { try {
await prisma.telegramReminderMessage.upsert({ await prisma.telegramReminderMessage.upsert({
where: { where: {
medicationId_scheduleId_date_phase_timeOfDay: { recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: userId,
medicationId, medicationId,
scheduleId, scheduleId,
date, date,
@@ -229,6 +222,7 @@ export async function sendViaTelegram(
}, },
}, },
create: { create: {
recipientUserId: userId,
medicationId, medicationId,
scheduleId, scheduleId,
chatId: config.chatId, chatId: config.chatId,
+14 -6
View File
@@ -90,10 +90,17 @@ function resolveBotLocale(value: string | null | undefined): Locale {
return "de"; return "de";
} }
async function cleanupReminderTracking(medicationId: string): Promise<void> { async function cleanupReminderTracking(
recipientUserId: string,
medicationId: string,
): Promise<void> {
try { try {
await prisma.telegramReminderMessage.deleteMany({ await prisma.telegramReminderMessage.deleteMany({
where: { medicationId }, where: {
recipientUserId,
medicationId,
medication: { userId: recipientUserId },
},
}); });
} catch { } catch {
// Best-effort cleanup // Best-effort cleanup
@@ -153,6 +160,7 @@ async function resolveReminderSlot(input: {
}): Promise<Date | null> { }): Promise<Date | null> {
const reminder = await prisma.telegramReminderMessage.findFirst({ const reminder = await prisma.telegramReminderMessage.findFirst({
where: { where: {
recipientUserId: input.userId,
medicationId: input.medicationId, medicationId: input.medicationId,
chatId: input.chatId, chatId: input.chatId,
messageId: input.messageId, messageId: input.messageId,
@@ -398,7 +406,7 @@ export async function handleCallback(update: TelegramUpdate) {
if (messageId) { if (messageId) {
await deleteMessage(botToken, chatId, messageId); await deleteMessage(botToken, chatId, messageId);
} }
await cleanupReminderTracking(medicationId); await cleanupReminderTracking(user.id, medicationId);
} else if (data.startsWith("snooze:")) { } else if (data.startsWith("snooze:")) {
// Format: "snooze:{medicationId}:{minutes}" // Format: "snooze:{medicationId}:{minutes}"
const parts = data.split(":"); const parts = data.split(":");
@@ -443,7 +451,7 @@ export async function handleCallback(update: TelegramUpdate) {
if (messageId) { if (messageId) {
await deleteMessage(botToken, chatId, messageId); await deleteMessage(botToken, chatId, messageId);
} }
await cleanupReminderTracking(medicationId); await cleanupReminderTracking(user.id, medicationId);
} else if (data.startsWith("skip:")) { } else if (data.startsWith("skip:")) {
const medicationId = data.slice("skip:".length).trim(); const medicationId = data.slice("skip:".length).trim();
if (!medicationId) { if (!medicationId) {
@@ -586,7 +594,7 @@ export async function handleCallback(update: TelegramUpdate) {
if (messageId) { if (messageId) {
await deleteMessage(botToken, chatId, messageId); await deleteMessage(botToken, chatId, messageId);
} }
await cleanupReminderTracking(medicationId); await cleanupReminderTracking(user.id, medicationId);
} else if (data.startsWith("ack:")) { } else if (data.startsWith("ack:")) {
const medicationId = data.slice("ack:".length).trim(); const medicationId = data.slice("ack:".length).trim();
if (!medicationId) { if (!medicationId) {
@@ -613,7 +621,7 @@ export async function handleCallback(update: TelegramUpdate) {
if (messageId) { if (messageId) {
await deleteMessage(botToken, chatId, messageId); await deleteMessage(botToken, chatId, messageId);
} }
await cleanupReminderTracking(medicationId); await cleanupReminderTracking(user.id, medicationId);
} else if (data.startsWith("add:")) { } else if (data.startsWith("add:")) {
// Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}" // Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}"
const withoutPrefix = data.slice("add:".length); const withoutPrefix = data.slice("add:".length);