Files
HealthLog/src/lib/illness/red-flag-notify.ts
T
Marc-André Bombeck 6c070abfdc feat(notifications): escalate urgent events to each channel's highest tier
Add an urgent dimension to dispatched notifications so a self-hosted
instance reaches every configured channel at its strongest level without
assuming APNs is present:

- APNs    -> interruption-level time-sensitive + priority 10 (critical
  only when APNS_CRITICAL_ENTITLEMENT=true; time-sensitive needs no
  Apple approval)
- ntfy    -> Priority 5 (max) + warning tag (suppressed in discreet mode)
- Web Push-> Urgency: high send option + requireInteraction
- Webhook -> priority "urgent" in the JSON body
- Telegram-> normal delivery (no urgency tier)

Wire the illness red-flag escalation (sustained low SpO2 / sustained
fever) to emit an urgent SYSTEM_ALERT through the dispatcher, owner-scoped
and module-gated, de-duplicated per episode via the push-attempt ledger.
Normal events are unchanged.
2026-06-18 11:28:18 +02:00

95 lines
3.7 KiB
TypeScript

/**
* v1.18.4 — illness red-flag escalation push.
*
* The illness correlation engine (`computeIllnessCorrelation`) surfaces a
* retrospective "seek care" red flag when a vital held an absolute clinical
* floor for a sustained run (sustained low SpO2, sustained fever). Until now
* that escalation only rendered in-app on the correlation surface; it never
* left a push. This helper bridges it to the notification dispatcher so the
* escalation reaches the user wherever they configured a channel.
*
* It dispatches at the URGENT level: every configured channel delivers at its
* highest urgency (APNs time-sensitive, ntfy max, Web Push `Urgency: high`,
* webhook `urgent`). There is NO dependency on APNs — an instance with only
* ntfy / Web Push / Telegram / webhook / email still gets the escalation at
* the strongest tier those channels support.
*
* Owner-scoped (the authenticated episode owner) and module-gated by the
* caller (`requireIllnessEnabled`). De-duplicated through the `pushAttempt`
* ledger: at most one escalation per episode per rolling window, so a
* correlation surface that is re-read repeatedly never re-fires the alarm.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import type { IllnessRedFlag } from "@/lib/illness/correlation";
/** One escalation per episode per this window. */
const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */
const LEDGER_REASON_PREFIX = "illness_red_flag:";
/**
* Emit an urgent escalation push for an episode's red flags, at most once
* per `DEDUPE_WINDOW_MS`. No-op when `redFlags` is empty. Never throws —
* a notification failure must not break the correlation read.
*/
export async function notifyIllnessRedFlag(input: {
userId: string;
episodeId: string;
redFlags: IllnessRedFlag[];
}): Promise<void> {
const { userId, episodeId, redFlags } = input;
if (redFlags.length === 0) return;
try {
// Dedupe: skip when an escalation for THIS episode already landed in the
// ledger within the window. The episode id rides the `reason` column so
// the lookup is exact without a schema change.
const reason = `${LEDGER_REASON_PREFIX}${episodeId}`;
const since = new Date(Date.now() - DEDUPE_WINDOW_MS);
const prior = await prisma.pushAttempt.findFirst({
where: { userId, reason, createdAt: { gte: since } },
select: { id: true },
});
if (prior) return;
// Prefer the most clinically actionable reason for the body. Both copy
// keys already exist in every locale bundle (i18n integrity guarantee).
const fever = redFlags.find((f) => f.reason === "sustained_fever");
const messageKey = fever
? "illness.correlation.redFlagFever"
: "illness.correlation.redFlagSpo2";
// Stamp the ledger BEFORE dispatching so a slow/duplicate concurrent read
// can't double-fire. The senders write their own per-channel rows too;
// this synthetic row is purely the dedupe anchor.
await prisma.pushAttempt.create({
data: {
userId,
channel: "WEB_PUSH",
eventType: "SYSTEM_ALERT",
result: "skipped",
reason,
},
});
await dispatchLocalisedNotification({
userId,
titleKey: "illness.correlation.redFlagTitle",
messageKey,
eventType: "SYSTEM_ALERT",
urgent: true,
});
getEvent()?.addMeta("illness_red_flag_notified", episodeId);
} catch (err) {
getEvent()?.addWarning(
`illness red-flag notify failed for episode ${episodeId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}