mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(notifications): offer no completion action on an appointment nudge
An appointment reminder rides the same MEASUREMENT_REMINDER event type as a checkup — one engine, deliberately — so every channel attached its Done / Later affordance to it: the Telegram inline keyboard and the APNs category whose actions the iOS app registers. Both resolve the reminder by id, and every by-id reminder surface refuses an ENCOUNTER-origin row, so each button could only answer "not found". The producer knows the origin, so it says the dispatch carries no action and each channel drops its own affordance rather than re-deriving the rule. The reminder id still rides the metadata: it identifies the nudge for the client-managed suppression tag, and what is withheld is the offer to act. - suppressActions on the notification payload, documented where it is declared - Telegram builds no measurement keyboard for a suppressed dispatch - APNs withholds the category and strips the reminder id from the iOS payload - a negative control keeps a checkup nudge actionable
This commit is contained in:
1 parent
f599eba671
commit
46dc31d10e
7 files changed
+166
-5
No files matched your search
@@ -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<DispatchFn>(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<DispatchFn>(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 })],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -592,6 +592,8 @@ export async function sendViaApns(
|
||||
metadata?: Record<string, unknown>;
|
||||
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<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!metadata) return metadata;
|
||||
const out: Record<string, unknown> = {};
|
||||
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"]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user