mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Mostly table column normalisation under docs/audit/ + planning, object- literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple Health metric block landed, and small wrap/unwrap line adjustments in the device + APNs routes / tests touched across W3/W4/W5 sessions. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
/**
|
|
* APNs sender — joins the dispatcher cascade alongside Telegram, ntfy,
|
|
* and Web Push (v1.4.23 Wave 3 / F4).
|
|
*
|
|
* Mirrors the `web-push.ts` shape so the dispatcher can call it through
|
|
* the same `SendOutcome`-returning contract:
|
|
* - `ok: true` if at least one device received the push.
|
|
* - `hardReject: true` when EVERY device returned a permanent
|
|
* APNs reason (`Unregistered`, `BadDeviceToken`, `DeviceTokenNotForTopic`).
|
|
* The dead device rows are deleted from the DB regardless, so a
|
|
* hard channel reject means the user has zero iOS devices left for
|
|
* the dispatcher to try.
|
|
* - `hardReject: false` for any other failure (transient APNs
|
|
* 5xx, throttling, network, certificate-invalid). The channel-state
|
|
* machine then schedules the next retry per the backoff schedule.
|
|
*
|
|
* Provider lifecycle: `@parse/node-apn` keeps the HTTP/2 connection
|
|
* pool + the JWT bearer (regenerated every ~50 minutes per Apple's spec)
|
|
* inside its `Provider` instance. We lazy-init one Provider per gateway
|
|
* (`sandbox` vs `production`) at module scope. **Do not** cache the JWT
|
|
* separately — that's what the library is for, and pinning a single
|
|
* token to the process lifetime would expire ~50 minutes after boot.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
|
|
import apn from "@parse/node-apn";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
|
|
|
export interface ApnsPayload {
|
|
/** APNs `aps.alert.title` + `aps.alert.body`. */
|
|
alert: { title: string; body: string };
|
|
/** APNs `aps.badge`. Omit to leave the existing badge unchanged. */
|
|
badge?: number;
|
|
/** APNs `aps.sound`. Omit for `default`. */
|
|
sound?: string;
|
|
/** Custom keys; surfaces in `Notification.userInfo` on iOS. */
|
|
data?: Record<string, unknown>;
|
|
/** APNs `aps.thread-id` — groups related alerts in Notification Center. */
|
|
threadId?: string;
|
|
}
|
|
|
|
export interface ApnsSendInput {
|
|
deviceToken: string;
|
|
environment: "sandbox" | "production";
|
|
payload: ApnsPayload;
|
|
/** Defaults to `APNS_BUNDLE_ID`. */
|
|
topic?: string;
|
|
/** Replaces an earlier alert of the same kind in Notification Center. */
|
|
collapseId?: string;
|
|
}
|
|
|
|
export interface ApnsSendResult {
|
|
ok: boolean;
|
|
/** APNs HTTP status, when the call reached Apple. */
|
|
status?: number;
|
|
/** Apple's error code, e.g. "BadDeviceToken", "Unregistered". */
|
|
reason?: string;
|
|
/**
|
|
* True on permanent failures (`Unregistered`, `BadDeviceToken`,
|
|
* `DeviceTokenNotForTopic`). The caller should remove the device row
|
|
* — sending again will only burn quota.
|
|
*/
|
|
shouldDisable?: boolean;
|
|
/** Human-readable error fragment for logs. */
|
|
message?: string;
|
|
}
|
|
|
|
interface ApnsConfig {
|
|
keyId: string;
|
|
teamId: string;
|
|
bundleId: string;
|
|
/** PEM-encoded .p8 contents (multi-line OK). */
|
|
signingKey: string;
|
|
/**
|
|
* If set, force every send through the production gateway regardless of
|
|
* the per-device `apnsEnvironment`. Useful for staging environments
|
|
* that have to talk to a TestFlight build.
|
|
*/
|
|
forceProduction: boolean;
|
|
}
|
|
|
|
/**
|
|
* Permanent APNs error reasons. Anything in this set means "delete the
|
|
* device row, do not retry". Sourced from Apple's
|
|
* `Sending Notification Requests to APNs` reference table.
|
|
*
|
|
* `DeviceTokenNotForTopic` lands here (rather than as a transient blip)
|
|
* because it indicates a configuration mismatch between the iOS bundle
|
|
* and the server-side topic — re-sending will fail forever.
|
|
*/
|
|
const PERMANENT_APNS_REASONS = new Set<string>([
|
|
"Unregistered",
|
|
"BadDeviceToken",
|
|
"DeviceTokenNotForTopic",
|
|
]);
|
|
|
|
let cachedConfig: ApnsConfig | null | undefined;
|
|
const providers = new Map<"sandbox" | "production", apn.Provider>();
|
|
|
|
/**
|
|
* Read the APNS_* env vars once per process. Returns null if any one of
|
|
* the required values is missing — that disables the channel cleanly
|
|
* rather than throwing on every dispatch.
|
|
*
|
|
* Validation rule: APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID, and ONE OF
|
|
* (APNS_KEY | APNS_KEY_FILE) must all be set together. Setting some but
|
|
* not others is treated as "intentionally disabled" and emits a warning
|
|
* the first time a send is attempted, so a half-finished Coolify env
|
|
* surfaces in the logs the moment it matters.
|
|
*/
|
|
export function loadApnsConfig(): ApnsConfig | null {
|
|
if (cachedConfig !== undefined) return cachedConfig;
|
|
|
|
const keyId = process.env.APNS_KEY_ID?.trim() || "";
|
|
const teamId = process.env.APNS_TEAM_ID?.trim() || "";
|
|
const bundleId = process.env.APNS_BUNDLE_ID?.trim() || "";
|
|
const inlineKey = process.env.APNS_KEY?.trim() || "";
|
|
const keyFile = process.env.APNS_KEY_FILE?.trim() || "";
|
|
const forceProduction = process.env.APNS_PRODUCTION === "true";
|
|
|
|
// All-or-none guard: if zero of the four core values are set, APNs is
|
|
// simply disabled — no warning. If some are set, the operator started
|
|
// configuring and stopped — surface that as a warning so the
|
|
// half-finished state is visible in logs.
|
|
const anySet = Boolean(keyId || teamId || bundleId || inlineKey || keyFile);
|
|
const allSet = Boolean(keyId && teamId && bundleId && (inlineKey || keyFile));
|
|
|
|
if (!anySet) {
|
|
cachedConfig = null;
|
|
return null;
|
|
}
|
|
|
|
if (!allSet) {
|
|
getEvent()?.addWarning(
|
|
"APNs config incomplete — set APNS_KEY_ID, APNS_TEAM_ID, " +
|
|
"APNS_BUNDLE_ID, and one of APNS_KEY / APNS_KEY_FILE together. " +
|
|
"APNs sender is disabled until all four are present.",
|
|
);
|
|
cachedConfig = null;
|
|
return null;
|
|
}
|
|
|
|
let signingKey: string;
|
|
if (inlineKey) {
|
|
// Multi-line PEM with literal `\n` escapes is the 12-factor norm
|
|
// when the env block is a single line. Convert them back here so
|
|
// node-apn sees a real PEM. No-op for already-multiline values.
|
|
signingKey = inlineKey.replace(/\\n/g, "\n");
|
|
} else {
|
|
try {
|
|
signingKey = readFileSync(keyFile, "utf8");
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "read_failed";
|
|
getEvent()?.addWarning(`APNs key file read failed: ${message}`);
|
|
cachedConfig = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
cachedConfig = { keyId, teamId, bundleId, signingKey, forceProduction };
|
|
return cachedConfig;
|
|
}
|
|
|
|
/**
|
|
* Reset the cached config + Provider singletons. Tests use this to
|
|
* re-read env vars between cases; production never calls it.
|
|
*/
|
|
export function resetApnsForTesting(): void {
|
|
cachedConfig = undefined;
|
|
for (const provider of providers.values()) {
|
|
void provider.shutdown();
|
|
}
|
|
providers.clear();
|
|
}
|
|
|
|
function getProvider(
|
|
config: ApnsConfig,
|
|
environment: "sandbox" | "production",
|
|
): apn.Provider {
|
|
const useProduction = config.forceProduction || environment === "production";
|
|
const key = useProduction ? "production" : "sandbox";
|
|
const existing = providers.get(key);
|
|
if (existing) return existing;
|
|
|
|
const provider = new apn.Provider({
|
|
token: {
|
|
key: config.signingKey,
|
|
keyId: config.keyId,
|
|
teamId: config.teamId,
|
|
},
|
|
production: useProduction,
|
|
});
|
|
providers.set(key, provider);
|
|
return provider;
|
|
}
|
|
|
|
/**
|
|
* Send one APNs alert push to one device. The dispatcher fans out per
|
|
* device because APNs can return per-device failure reasons that map to
|
|
* different cleanup actions.
|
|
*/
|
|
export async function sendApnsPush(
|
|
input: ApnsSendInput,
|
|
): Promise<ApnsSendResult> {
|
|
const config = loadApnsConfig();
|
|
if (!config) {
|
|
return {
|
|
ok: false,
|
|
reason: "apns_not_configured",
|
|
message: "APNs env vars are not set",
|
|
};
|
|
}
|
|
|
|
const provider = getProvider(config, input.environment);
|
|
|
|
const note = new apn.Notification();
|
|
note.topic = input.topic ?? config.bundleId;
|
|
note.alert = {
|
|
title: input.payload.alert.title,
|
|
body: input.payload.alert.body,
|
|
};
|
|
note.sound = input.payload.sound ?? "default";
|
|
if (typeof input.payload.badge === "number") {
|
|
note.badge = input.payload.badge;
|
|
}
|
|
if (input.payload.threadId) {
|
|
note.threadId = input.payload.threadId;
|
|
}
|
|
if (input.collapseId) {
|
|
note.collapseId = input.collapseId;
|
|
}
|
|
if (input.payload.data) {
|
|
note.payload = { ...input.payload.data };
|
|
}
|
|
|
|
const start = performance.now();
|
|
let result: Awaited<ReturnType<apn.Provider["send"]>>;
|
|
try {
|
|
result = await provider.send(note, input.deviceToken);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "send_failed";
|
|
getEvent()?.addExternalCall({
|
|
service: "apns",
|
|
method: "send",
|
|
duration_ms: Math.round(performance.now() - start),
|
|
error: message,
|
|
});
|
|
return { ok: false, reason: "apns_network_error", message };
|
|
}
|
|
|
|
const duration = Math.round(performance.now() - start);
|
|
|
|
if (result.sent.length > 0) {
|
|
getEvent()?.addExternalCall({
|
|
service: "apns",
|
|
method: "send",
|
|
duration_ms: duration,
|
|
status: 200,
|
|
});
|
|
return { ok: true, status: 200 };
|
|
}
|
|
|
|
const failure = result.failed[0];
|
|
const reason = failure?.response?.reason ?? failure?.error?.message;
|
|
const status = failure?.status ? Number(failure.status) : undefined;
|
|
const shouldDisable = reason ? PERMANENT_APNS_REASONS.has(reason) : false;
|
|
|
|
getEvent()?.addExternalCall({
|
|
service: "apns",
|
|
method: "send",
|
|
duration_ms: duration,
|
|
status: status ?? undefined,
|
|
error: reason ?? "all_failed",
|
|
});
|
|
|
|
return {
|
|
ok: false,
|
|
status,
|
|
reason: reason ?? "apns_unknown_failure",
|
|
shouldDisable,
|
|
message: failure?.error?.message,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Dispatcher entry point — fans a payload out to every iOS device the
|
|
* user owns and returns a single `SendOutcome` summarising the batch.
|
|
*
|
|
* Mirrors `sendViaWebPush(userId, payload)` so `dispatcher.ts` can route
|
|
* the APNS branch identically to its web-push branch:
|
|
* - At least one success ⇒ `ok: true`.
|
|
* - All devices permanently rejected ⇒ `hardReject: true` (channel
|
|
* auto-disables; user has no iOS device left to retry against).
|
|
* - Otherwise ⇒ soft failure, channel-state machine schedules backoff.
|
|
*/
|
|
export async function sendViaApns(
|
|
userId: string,
|
|
payload: {
|
|
title: string;
|
|
message: string;
|
|
eventType: string;
|
|
metadata?: Record<string, unknown>;
|
|
},
|
|
): Promise<SendOutcome> {
|
|
const config = loadApnsConfig();
|
|
if (!config) {
|
|
return {
|
|
ok: false,
|
|
hardReject: false,
|
|
reason: "apns_not_configured",
|
|
};
|
|
}
|
|
|
|
const devices = await prisma.device.findMany({
|
|
where: {
|
|
userId,
|
|
apnsToken: { not: null },
|
|
},
|
|
select: {
|
|
id: true,
|
|
apnsToken: true,
|
|
apnsEnvironment: true,
|
|
},
|
|
});
|
|
|
|
if (devices.length === 0) {
|
|
// Same rationale as web-push: a user who hasn't paired an iPhone yet
|
|
// shouldn't see the channel auto-disabled. Treat as soft "no recipient".
|
|
return {
|
|
ok: false,
|
|
hardReject: false,
|
|
reason: "apns_no_devices",
|
|
};
|
|
}
|
|
|
|
let anySuccess = false;
|
|
const deadDeviceIds: string[] = [];
|
|
let lastFailureReason: string | undefined;
|
|
let lastFailureStatus: number | undefined;
|
|
|
|
for (const device of devices) {
|
|
if (!device.apnsToken) continue;
|
|
const env =
|
|
device.apnsEnvironment === "production" ? "production" : "sandbox";
|
|
|
|
const result = await sendApnsPush({
|
|
deviceToken: device.apnsToken,
|
|
environment: env,
|
|
payload: {
|
|
alert: { title: payload.title, body: stripHtml(payload.message) },
|
|
data: {
|
|
eventType: payload.eventType,
|
|
...(payload.metadata ?? {}),
|
|
},
|
|
threadId: payload.eventType,
|
|
},
|
|
collapseId: payload.eventType,
|
|
});
|
|
|
|
if (result.ok) {
|
|
anySuccess = true;
|
|
continue;
|
|
}
|
|
|
|
if (result.shouldDisable) {
|
|
deadDeviceIds.push(device.id);
|
|
}
|
|
lastFailureReason = result.reason;
|
|
lastFailureStatus = result.status;
|
|
}
|
|
|
|
if (deadDeviceIds.length > 0) {
|
|
await prisma.device.deleteMany({
|
|
where: { id: { in: deadDeviceIds } },
|
|
});
|
|
}
|
|
|
|
if (anySuccess) {
|
|
return { ok: true };
|
|
}
|
|
|
|
// Every surviving device returned a permanent reason → channel is dead
|
|
// until the user re-registers from a fresh iOS install.
|
|
if (deadDeviceIds.length === devices.length) {
|
|
return {
|
|
ok: false,
|
|
hardReject: true,
|
|
statusCode: lastFailureStatus,
|
|
reason: lastFailureReason ?? "apns_all_devices_unregistered",
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
hardReject: false,
|
|
statusCode: lastFailureStatus,
|
|
reason: lastFailureReason ?? "apns_send_failed",
|
|
};
|
|
}
|
|
|
|
function stripHtml(s: string): string {
|
|
return s.replace(/<[^>]*>/g, "");
|
|
}
|