mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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.
95 lines
3.7 KiB
TypeScript
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)
|
|
}`,
|
|
);
|
|
}
|
|
}
|