From fa46f27b014fa967bcdf74f06fc3d331aecfecc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:38:09 +0200 Subject: [PATCH] feat: scope Telegram reminder tracking to recipients - keep delegated recipient slots independent\n- bind callback cleanup to the recipient account --- .../migration.sql | 28 +++++++++++++++ prisma/schema.prisma | 27 +++++++------- .../telegram/webhook/__tests__/route.test.ts | 18 ++++++++++ .../senders/__tests__/telegram.test.ts | 35 +++++++++---------- src/lib/notifications/senders/telegram.ts | 22 +++++------- src/lib/telegram-webhook-handlers.ts | 20 +++++++---- 6 files changed, 99 insertions(+), 51 deletions(-) diff --git a/prisma/migrations/0298_notification_delivery_identity/migration.sql b/prisma/migrations/0298_notification_delivery_identity/migration.sql index e87ea0c31..27691f2e4 100644 --- a/prisma/migrations/0298_notification_delivery_identity/migration.sql +++ b/prisma/migrations/0298_notification_delivery_identity/migration.sql @@ -50,6 +50,34 @@ CREATE TABLE "notification_events" ( 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); +-- 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() RETURNS TRIGGER LANGUAGE plpgsql diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eafd0eed3..153febee0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -863,6 +863,7 @@ model User { pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser") pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser") notificationEvents NotificationEvent[] + telegramReminderMessages TelegramReminderMessage[] // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total // TRIMP + running EWMA reference) so the nightly Strain engine anchors // the 0–100 map to the user's own training load without re-integrating @@ -3364,24 +3365,26 @@ model ReminderPhaseConfig { } model TelegramReminderMessage { - id String @id @default(cuid()) - medicationId String @map("medication_id") - scheduleId String @map("schedule_id") - chatId String @map("chat_id") - messageId Int @map("message_id") - phase ReminderPhase - sentAt DateTime @default(now()) @map("sent_at") - date String // YYYY-MM-DD for day grouping + id String @id @default(cuid()) + recipientUserId String @map("recipient_user_id") + medicationId String @map("medication_id") + scheduleId String @map("schedule_id") + chatId String @map("chat_id") + messageId Int @map("message_id") + phase ReminderPhase + 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). /// Empty string = the single legacy window (pre-v1.7 rows + single-time /// 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. - timeOfDay String @default("") @map("time_of_day") + timeOfDay String @default("") @map("time_of_day") - medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) - schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + recipientUser User @relation(fields: [recipientUserId], 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]) @@map("telegram_reminder_messages") } diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 634857667..05431bd59 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -291,6 +291,14 @@ describe("Telegram webhook — callback dispatch", () => { const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); 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. expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); 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"))); 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. expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index 67a6ed488..74aaaaf58 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -15,7 +15,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const sendTelegramMessageMock = vi.fn(); const deleteMessageMock = vi.fn(); const findUniqueMock = vi.fn(); -const deleteMock = vi.fn(); const upsertMock = vi.fn(); const userFindUniqueMock = vi.fn(); @@ -31,7 +30,6 @@ vi.mock("@/lib/db", () => ({ }, telegramReminderMessage: { findUnique: (...args: unknown[]) => findUniqueMock(...args), - delete: (...args: unknown[]) => deleteMock(...args), upsert: (...args: unknown[]) => upsertMock(...args), }, }, @@ -79,7 +77,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 999 }); deleteMessageMock.mockResolvedValue(undefined); findUniqueMock.mockResolvedValue(null); - deleteMock.mockResolvedValue(undefined); upsertMock.mockResolvedValue(undefined); userFindUniqueMock.mockResolvedValue({ locale: "en" }); 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. findUniqueMock.mockResolvedValue({ 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... expect(deleteMessageMock).toHaveBeenCalledWith("bot-token", "chat-1", 123); - // ...and exactly that single ledger row is removed (scoped delete, not deleteMany). - expect(deleteMock).toHaveBeenCalledTimes(1); - expect(deleteMock).toHaveBeenCalledWith({ - where: { - recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { - recipientUserId: "user-1", - medicationId: "med-1", - scheduleId: "sched-1", - date: "2025-06-10", - phase: "YELLOW", - timeOfDay: "20:00", + // The tracking row stays in place so the recipient-scoped upsert updates + // it rather than creating a second row when the chat binding changes. + expect(upsertMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: "user-1", + medicationId: "med-1", + scheduleId: "sched-1", + date: "2025-06-10", + phase: "YELLOW", + timeOfDay: "20:00", + }, }, - }, - }); + }), + ); }); 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()); expect(deleteMessageMock).not.toHaveBeenCalled(); - expect(deleteMock).not.toHaveBeenCalled(); // It still sends + tracks the new message. expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1); expect(upsertMock).toHaveBeenCalledTimes(1); @@ -155,7 +153,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { ); expect(findUniqueMock).not.toHaveBeenCalled(); - expect(deleteMock).not.toHaveBeenCalled(); }); it("keeps two recipients' identical subject slots independent", async () => { diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts index f5d35b497..a98727338 100644 --- a/src/lib/notifications/senders/telegram.ts +++ b/src/lib/notifications/senders/telegram.ts @@ -52,7 +52,7 @@ export interface TelegramSendResult extends SendOutcome { /** * 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 - * `{ 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 * medication with `timesOfDay = ["08:00","20:00"]` has two distinct * 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( botToken: string, + recipientUserId: string, medicationId: string, scheduleId: string, date: string, @@ -71,7 +72,8 @@ async function deleteExistingReminders( try { const existing = await prisma.telegramReminderMessage.findUnique({ where: { - medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId, medicationId, scheduleId, date, @@ -89,17 +91,6 @@ async function deleteExistingReminders( // Best-effort: message may already be deleted } - await prisma.telegramReminderMessage.delete({ - where: { - medicationId_scheduleId_date_phase_timeOfDay: { - medicationId, - scheduleId, - date, - phase, - timeOfDay, - }, - }, - }); } catch (err) { getEvent()?.addWarning(`Failed to delete existing reminders: ${err}`); } @@ -136,6 +127,7 @@ export async function sendViaTelegram( if (medicationId && scheduleId && phase && date) { await deleteExistingReminders( config.botToken, + userId, medicationId, scheduleId, date, @@ -220,7 +212,8 @@ export async function sendViaTelegram( try { await prisma.telegramReminderMessage.upsert({ where: { - medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: userId, medicationId, scheduleId, date, @@ -229,6 +222,7 @@ export async function sendViaTelegram( }, }, create: { + recipientUserId: userId, medicationId, scheduleId, chatId: config.chatId, diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 6bd22fec3..7bd4568e2 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -90,10 +90,17 @@ function resolveBotLocale(value: string | null | undefined): Locale { return "de"; } -async function cleanupReminderTracking(medicationId: string): Promise { +async function cleanupReminderTracking( + recipientUserId: string, + medicationId: string, +): Promise { try { await prisma.telegramReminderMessage.deleteMany({ - where: { medicationId }, + where: { + recipientUserId, + medicationId, + medication: { userId: recipientUserId }, + }, }); } catch { // Best-effort cleanup @@ -153,6 +160,7 @@ async function resolveReminderSlot(input: { }): Promise { const reminder = await prisma.telegramReminderMessage.findFirst({ where: { + recipientUserId: input.userId, medicationId: input.medicationId, chatId: input.chatId, messageId: input.messageId, @@ -398,7 +406,7 @@ export async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } - await cleanupReminderTracking(medicationId); + await cleanupReminderTracking(user.id, medicationId); } else if (data.startsWith("snooze:")) { // Format: "snooze:{medicationId}:{minutes}" const parts = data.split(":"); @@ -443,7 +451,7 @@ export async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } - await cleanupReminderTracking(medicationId); + await cleanupReminderTracking(user.id, medicationId); } else if (data.startsWith("skip:")) { const medicationId = data.slice("skip:".length).trim(); if (!medicationId) { @@ -586,7 +594,7 @@ export async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } - await cleanupReminderTracking(medicationId); + await cleanupReminderTracking(user.id, medicationId); } else if (data.startsWith("ack:")) { const medicationId = data.slice("ack:".length).trim(); if (!medicationId) { @@ -613,7 +621,7 @@ export async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } - await cleanupReminderTracking(medicationId); + await cleanupReminderTracking(user.id, medicationId); } else if (data.startsWith("add:")) { // Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}" const withoutPrefix = data.slice("add:".length);