Files
NextChair/src/services/notifications/providers/web-push-provider.ts
T
2026-08-03 21:15:24 +01:00

63 lines
2.5 KiB
TypeScript

import webpush from "web-push";
import type { PushProvider, PushSendResult, SendPushParams } from "./push-provider";
/**
* Standards-based Web Push (RFC 8030 + VAPID) via the `web-push` package —
* works against any browser's push service (Chrome/FCM, Firefox, Safari)
* without a vendor-specific SDK. Nothing here is Brevo-specific; push and
* email are unrelated delivery channels with unrelated providers.
*/
export interface WebPushProviderConfig {
publicKey: string;
privateKey: string;
subject: string;
}
export function loadWebPushProviderConfigFromEnv(): WebPushProviderConfig {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
throw new Error("Web Push is not configured — VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must all be set. See .env.example.");
}
return { publicKey, privateKey, subject };
}
interface WebPushError {
statusCode?: number;
message?: string;
}
export class WebPushProvider implements PushProvider {
readonly name = "web-push";
constructor(config: WebPushProviderConfig) {
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
}
async sendPush(params: SendPushParams): Promise<PushSendResult> {
try {
await webpush.sendNotification(
{
endpoint: params.subscription.endpoint,
keys: { p256dh: params.subscription.p256dhKey, auth: params.subscription.authKey },
},
JSON.stringify({ title: params.title, body: params.body, url: params.deepLinkPath }),
);
return { status: "sent" };
} catch (error) {
const webPushError = error as WebPushError;
// 404/410: the push service itself says this subscription no longer
// exists (browser data cleared, permission revoked, uninstalled) —
// never retryable, and the caller should revoke the row.
if (webPushError.statusCode === 404 || webPushError.statusCode === 410) {
return { status: "failed", retryable: false, subscriptionInvalid: true, reason: "Subscription is no longer valid." };
}
if (typeof webPushError.statusCode === "number" && webPushError.statusCode >= 500) {
return { status: "failed", retryable: true, reason: webPushError.message ?? "Push service error." };
}
return { status: "failed", retryable: false, reason: webPushError.message ?? "Unknown push error." };
}
}
}