Files
HealthLog/src/lib/notifications/client-managed-apns.ts
T
Marc-André Bombeck bb563b6506 Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
2026-10-08 10:32:11 +02:00

99 lines
4.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>;
/**
* Which dispatches of the event the flag covers. Absent: all of them. The
* flag says the phone delivers this reminder itself, so it covers exactly
* what the phone replaces and nothing the phone does not send.
*/
covers?: (metadata: Record<string, unknown> | undefined) => boolean;
}
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),
}),
// The iOS client reminds once, at dose time, and sends no follow-ups of
// its own (iOS #116, item 21). So the flag keeps only the reminder that
// announces the dose off APNs; the escalations for a dose that is still
// open (`followUp: true`, set by the reminder cron) are pushed as usual.
// A dispatch that does not say is treated as the announcement, the
// behaviour every reminder had before the cron started saying.
covers: (metadata) => metadata?.followUp !== true,
},
MEASUREMENT_REMINDER: {
isClientManaged: isMeasurementReminderClientManaged,
metaKey: "measurement_reminder.suppressed_client_managed",
tag: (metadata) => str(metadata?.reminderId),
},
};
/**
* Does a client-managed opt-out apply to this dispatch at all? Cheap check
* the dispatcher runs before paying for the preferences read: the event type
* has to have the opt-out, and the opt-out has to cover this dispatch.
*/
export function hasClientManagedApnsGate(
eventType: string,
metadata?: Record<string, unknown>,
): boolean {
const gate = CLIENT_MANAGED_APNS_EVENTS[eventType];
if (!gate) return false;
return gate.covers ? gate.covers(metadata) : true;
}