Files
HealthLog/src/lib/notifications/senders/web-push.ts
T
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00

95 lines
2.5 KiB
TypeScript

import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import type { NotificationPayload } from "@/lib/notifications/types";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { getEvent } from "@/lib/logging/context";
/**
* Send Web Push notification to all subscribed devices of a user.
* VAPID config is loaded from app settings first, then env as fallback.
* Returns true if at least one delivery succeeded.
*/
export async function sendViaWebPush(
userId: string,
payload: NotificationPayload,
): Promise<boolean> {
try {
// Lazy import to avoid issues when web-push is not installed
const webpush = await import("web-push");
const config = await getVapidConfig();
if (!config) {
getEvent()?.addWarning("Web Push: VAPID keys not configured");
return false;
}
webpush.setVapidDetails(
config.subject,
config.publicKey,
config.privateKey,
);
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId },
});
if (!subscriptions.length) return false;
const pushPayload = JSON.stringify({
title: payload.title,
body: payload.message.replace(/<[^>]*>/g, ""),
tag: payload.eventType,
url: "/",
});
let anySuccess = false;
const expiredIds: string[] = [];
const pushStart = performance.now();
for (const sub of subscriptions) {
try {
const p256dh = decrypt(sub.p256dh);
const auth = decrypt(sub.auth);
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh, auth },
},
pushPayload,
);
anySuccess = true;
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 410 || status === 404) {
// Subscription expired or invalid — mark for cleanup
expiredIds.push(sub.id);
}
}
}
getEvent()?.addExternalCall({
service: "web-push",
method: "sendNotification",
duration_ms: Math.round(performance.now() - pushStart),
error: anySuccess ? undefined : "all_failed",
});
// Clean up expired subscriptions
if (expiredIds.length > 0) {
await prisma.pushSubscription.deleteMany({
where: { id: { in: expiredIds } },
});
}
return anySuccess;
} catch (err) {
getEvent()?.setError(
err instanceof Error
? err
: new Error("[web-push] sendViaWebPush failed"),
);
return false;
}
}