Files
HealthLog/src/lib/notifications/client-managed-apns.ts
T
Marc-André Bombeck fcc4e661df fix(notifications): scope client-managed suppression to the APNs leg
`medication.clientManaged` and `measurementReminder.clientManaged` mean
the iOS client schedules the reminder locally, so the server should not
push it. That is what the OpenAPI description, the settings copy and the
iOS contract all say. What the crons did was skip the dispatch entirely,
so a user who flipped the switch also lost Telegram, ntfy, webhook,
email and Web Push — channels they may well read on a desktop while the
phone holding the local schedule sits in a drawer.

Move the gate into the dispatcher's channel loop, where it applies to
APNs and nothing else. The skip is not counted as an attempt, so
`dispatched` keeps meaning "something reached the user", and it stays
countable: the same wide-event keys the crons emitted are emitted here,
plus a `skipped` row in the push-attempt ledger.

The Vorsorge path is the sharper half. It suppressed every channel and
then advanced `nextDueAt`, which dropped the reminder out of the
preventive-care card and out of the daily digest: a reminder that never
reached anyone was recorded as handled. The advance now happens only
where it always belonged, after a dispatch that a channel actually
delivered. When nothing delivers, the reminder stays overdue and visible.
2026-07-27 14:26:20 +02:00

80 lines
3.1 KiB
TypeScript

/**
* Client-managed suppression — the APNs leg only.
*
* A user whose iOS client schedules its own local reminders sets
* `notificationPrefs.<category>.clientManaged` (or the medication
* `deliveryDefault: "client"` that maps onto it). The published contract,
* the OpenAPI description and every copy site say the same thing: it
* suppresses the server's APNs send. It used to suppress the whole
* dispatch, so a user who flipped it also lost Telegram, ntfy, webhook,
* email and Web Push — channels they may read on a device that is not the
* phone holding the local schedule.
*
* The gate therefore lives here, next to the channel loop, and applies to
* exactly one channel. The per-skip wide-event annotation is preserved so
* the suppression stays countable; the annotation key per category is the
* one the crons emitted before the move.
*/
import {
isMeasurementReminderClientManaged,
isMedicationReminderClientManaged,
} from "@/lib/validations/notification-prefs";
interface ClientManagedGate {
/** Reads the category's flag out of the raw `notificationPrefs` blob. */
isClientManaged: (raw: unknown) => boolean;
/** Wide-event meta key, unchanged from the cron that used to emit it. */
metaKey: string;
/** Stable per-skip identifier built from the dispatch payload metadata. */
tag: (metadata: Record<string, unknown> | undefined) => string;
/**
* Optional structured companion, emitted under `detailKey`. Same key and
* same shape the cron used to emit, so an operator's existing read of the
* wide event still resolves the medication, schedule and dose instant.
*/
detailKey?: string;
detail?: (
userId: string,
metadata: Record<string, unknown> | undefined,
) => Record<string, unknown>;
}
function str(value: unknown): string {
return typeof value === "string" ? value : "";
}
/**
* Closed map: only these event types honour a client-managed flag. Every
* other event dispatches to APNs unconditionally. Cycle reminders are not
* listed — that cron still resolves its own flag before dispatching.
*/
export const CLIENT_MANAGED_APNS_EVENTS: Record<string, ClientManagedGate> = {
MEDICATION_REMINDER: {
isClientManaged: isMedicationReminderClientManaged,
metaKey: "medication_reminder_suppressed_client_managed",
tag: (metadata) =>
`${str(metadata?.medicationId)}:${str(metadata?.scheduledAt)}`,
detailKey: "medication_reminder_suppressed_meta",
detail: (userId, metadata) => ({
user_id: userId,
medication_id: str(metadata?.medicationId),
schedule_id: str(metadata?.scheduleId),
phase: str(metadata?.phase),
dose_at: str(metadata?.scheduledAt),
}),
},
MEASUREMENT_REMINDER: {
isClientManaged: isMeasurementReminderClientManaged,
metaKey: "measurement_reminder.suppressed_client_managed",
tag: (metadata) => str(metadata?.reminderId),
},
};
/**
* Does this event type have a client-managed opt-out at all? Cheap check
* the dispatcher runs before paying for the preferences read.
*/
export function hasClientManagedApnsGate(eventType: string): boolean {
return eventType in CLIENT_MANAGED_APNS_EVENTS;
}