diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index e2936555f..bf813ce74 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -106,6 +106,8 @@ describe("evaluateMeasurementReminderDue — window boundary", () => { interface FakeReminder { id: string; + /** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */ + origin?: string; measurementType: string | null; intervalDays: number | null; rrule: string | null; @@ -248,6 +250,46 @@ describe("runMeasurementReminderTick", () => { expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); }); + it("dispatches an appointment nudge with its completion affordance suppressed", async () => { + // `measure_done` / `measure_later` and the iOS "Erledigt" action all + // resolve the reminder by id, and every by-id surface refuses an + // ENCOUNTER-origin row. The producer knows the origin, so it is the + // producer that says the dispatch carries no action; the channels drop + // their own affordance from the flag. + const { prisma } = makePrisma({ + reminders: [ + reminder({ origin: "ENCOUNTER", measurementType: null, id: "appt-1" }), + ], + measurementMatch: null, + labMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(dispatch).toHaveBeenCalledTimes(1); + const payload = dispatch.mock.calls[0][0]; + expect(payload.suppressActions).toBe(true); + // The id still rides the dispatch — it identifies the nudge for the + // client-managed suppression tag. What is withheld is the offer to act. + expect(payload.metadata?.reminderId).toBe("appt-1"); + }); + + it("leaves a checkup nudge actionable", async () => { + // The negative control. Without it the assertion above would still pass + // against a producer that suppressed every reminder's affordance. + const { prisma } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].suppressActions).toBeUndefined(); + }); + it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: null })], diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 880f0b6cf..c6a467ffa 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -338,12 +338,20 @@ export async function runMeasurementReminderTick( return { title: rendered.title, message: rendered.body }; }; + // An appointment nudge is dispatched without its Done / Later + // affordance. It rides the same event type as a checkup — one engine, + // one cron, deliberately — but every by-id reminder surface refuses an + // ENCOUNTER-origin row: satisfying one would mark an appointment + // attended from a notification, and postponing one would move a date the + // visit record still believes it owns. The channels each drop their own + // affordance from this flag rather than re-deriving the origin. const outcome = await dispatchImpl({ eventType: "MEASUREMENT_REMINDER", userId: reminder.user.id, title, message: body, renderForRecipient, + ...(reminder.origin === "ENCOUNTER" ? { suppressActions: true } : {}), metadata: { scheduledAt: now.toISOString(), reminderId: reminder.id, diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 2294c3790..7d567c230 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -674,6 +674,32 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.payload.replyMarkup).toBeUndefined(); }); + it("withholds the category and the reminder id when actions are suppressed", async () => { + // The category IS the action row on iOS, and the id is what the action + // would POST. An appointment nudge has neither: the by-id reminder family + // refuses an ENCOUNTER-origin row, so both are dropped and the push + // arrives as a plain alert. The event type still routes it. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEASUREMENT_REMINDER", + suppressActions: true, + metadata: { + reminderId: "rem-1", + scheduledAt: "2026-08-08T09:00:00.000Z", + }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.category).toBeUndefined(); + expect(note.payload.reminderId).toBeUndefined(); + expect(note.payload.eventType).toBe("MEASUREMENT_REMINDER"); + expect(note.payload.scheduledAt).toBe("2026-08-08T09:00:00.000Z"); + }); + it("forwards MOOD_REMINDER eventType as aps.category too", async () => { // SR-2 contract: the mood-reminder daily job emits this event-type; // the iOS app registers a `MOOD_REMINDER` category whose only diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index fdbc30d10..14455e171 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -318,6 +318,23 @@ describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => { ); }); + it("offers no keyboard on a MEASUREMENT_REMINDER whose actions are suppressed", async () => { + // An appointment nudge. The reminder id is present and the event type + // matches, so only the flag stands between the person and two buttons + // that can answer nothing but "not found". + await sendViaTelegram(config, { + eventType: "MEASUREMENT_REMINDER", + userId: "user-1", + title: "t", + message: "m", + suppressActions: true, + metadata: { reminderId: "rem-1" }, + }); + const opts = sendTelegramMessageMock.mock.calls[0][3]; + expect(opts.replyMarkup).toBeUndefined(); + expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled(); + }); + it("sends a plain MEASUREMENT_REMINDER (no keyboard, no self-clean) without a reminderId", async () => { await sendViaTelegram(config, { eventType: "MEASUREMENT_REMINDER", diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 83bbccc71..6ea4afdc7 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -592,6 +592,8 @@ export async function sendViaApns( metadata?: Record; discreet?: boolean; urgent?: boolean; + /** The dispatch offers no completion action — see `NotificationPayload`. */ + suppressActions?: boolean; recordUserId?: string; recipientUserId?: string; }, @@ -732,12 +734,21 @@ export async function sendViaApns( // an iOS action identifier or deep link that can mutate it. // Keep the ordinary event envelope so opening the notification // follows the normal authorised record-selection flow. + // `suppressActions` removes the act-on-it keys for the same + // reason it clears the category below: the affordance the app + // would build from them resolves to a refusal. ...pickIosMetadata( managedDelivery ? undefined - : payload.discreet - ? stripCycleMetadata(payload.metadata) - : payload.metadata, + : payload.suppressActions + ? stripActionMetadata( + payload.discreet + ? stripCycleMetadata(payload.metadata) + : payload.metadata, + ) + : payload.discreet + ? stripCycleMetadata(payload.metadata) + : payload.metadata, ), }, threadId: routingEvent, @@ -746,7 +757,13 @@ export async function sendViaApns( // ignores categories it doesn't know about, so adding the key // here is safe for event-types that don't have an actionable // category registered yet — they fall back to a plain alert. - category: managedDelivery ? undefined : routingEvent, + // A suppressed-action dispatch drops the category for the same + // reason a managed delivery does: the category IS the action row, + // and an appointment nudge has no action the server would accept. + category: + managedDelivery || payload.suppressActions + ? undefined + : routingEvent, // Future-proof for an iOS Notification Service Extension. iOS // ignores the flag when no extension is registered, so it's a // no-op on today's binary but unblocks NSE work without a @@ -1028,6 +1045,32 @@ function pickIosMetadata( return out; } +/** + * Metadata keys that exist so the iOS app can ACT on the notification rather + * than merely read it. Dropped when the producer says the dispatch carries no + * completion affordance. + */ +const ACTION_METADATA_KEYS = new Set(["reminderId"]); + +/** + * Strip the act-on-it keys from a payload whose actions the server will refuse. + * + * An appointment nudge rides the same `MEASUREMENT_REMINDER` event type as a + * checkup, so the iOS app would offer the registered "Erledigt" action and + * POST a completion the by-id reminder family answers 404 for. Removing the + * id is what removes the action: the handler has nothing to complete. + */ +function stripActionMetadata( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) return metadata; + const out: Record = {}; + for (const key of Object.keys(metadata)) { + if (!ACTION_METADATA_KEYS.has(key)) out[key] = metadata[key]; + } + return out; +} + /** Cycle-semantic metadata keys that must never ride a discreet payload. */ const CYCLE_METADATA_KEYS = new Set(["phase"]); diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts index 8a2bf2479..205bde142 100644 --- a/src/lib/notifications/senders/telegram.ts +++ b/src/lib/notifications/senders/telegram.ts @@ -150,7 +150,15 @@ export async function sendViaTelegram( interactiveKeyboard = buildMoodKeyboard( await resolveSenderLocale(userId), ); - } else if (payload.eventType === "MEASUREMENT_REMINDER" && reminderId) { + } else if ( + payload.eventType === "MEASUREMENT_REMINDER" && + reminderId && + // An appointment nudge is dispatched with the affordance suppressed: + // `measure_done` / `measure_later` both refuse an ENCOUNTER-origin row, + // so offering the pair would put two buttons in the chat that can only + // answer "not found". + !payload.suppressActions + ) { interactiveKeyboard = buildMeasurementKeyboard( reminderId, await resolveSenderLocale(userId), diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index a8db92882..ce78a0e31 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -319,6 +319,23 @@ export interface NotificationPayload { * Normal events leave `urgent` unset and behave exactly as before. */ urgent?: boolean; + /** + * The nudge carries no completion affordance. + * + * A reminder push normally ships a Done / Later pair — the Telegram inline + * keyboard and the APNs category whose actions the iOS app registers. Both + * resolve the reminder by id and both are refused for an appointment: an + * appointment reminder belongs to a visit, is managed through the visit + * routes, and every by-id reminder surface answers "not found" for one. An + * offered button the server will refuse is worse than no button, so the + * producer that knows the origin says so here and each channel drops its own + * affordance rather than each channel re-deriving the rule. + * + * The reminder's id still rides `metadata` — it identifies the dispatch for + * the client-managed suppression tag and the wide event; what this flag + * removes is the OFFER to act on it. + */ + suppressActions?: boolean; } /**