155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
import webpush from "web-push";
|
|
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
|
import type { I18n, MessageDescriptor } from "@lingui/core";
|
|
import { vapidConfig, isPushConfigured } from "@/lib/config";
|
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
|
import { logger } from "@/lib/logger";
|
|
import { createI18n, resolveLocale } from "@/lib/i18n";
|
|
import type { PushSubscriptionRecord } from "@/lib/pocketbase/services/push";
|
|
|
|
export interface PushPayload {
|
|
title: string;
|
|
body: string;
|
|
/** Path (or absolute URL) to open/focus when the notification is clicked. */
|
|
url?: string;
|
|
/** Optional icon override; defaults to the app icon in the SW handler. */
|
|
icon?: string;
|
|
/** Optional tag to collapse/replace notifications of the same kind. */
|
|
tag?: string;
|
|
}
|
|
|
|
/** A literal string (e.g. the brand name, never translated) or a localizable message descriptor. */
|
|
export type PushText = string | MessageDescriptor;
|
|
|
|
export interface LocalizedPushPayload extends Omit<PushPayload, "title" | "body"> {
|
|
title: PushText;
|
|
body: PushText;
|
|
}
|
|
|
|
function resolvePushText(i18n: I18n, text: PushText): string {
|
|
return typeof text === "string" ? text : i18n._(text);
|
|
}
|
|
|
|
/**
|
|
* sendPushToPlayer only has the recipient's playerId, not a request context,
|
|
* so it resolves their locale from SuperTokens user metadata directly.
|
|
* Falls back to the default locale for players with no linked auth account
|
|
* or no stored locale preference.
|
|
*/
|
|
async function resolveRecipientLocale(playerId: string) {
|
|
try {
|
|
const player = await pbAdmin.getPlayer(playerId);
|
|
if (!player.auth_id) return resolveLocale(undefined);
|
|
const { metadata } = await UserMetadata.getUserMetadata(player.auth_id);
|
|
return resolveLocale(metadata?.locale);
|
|
} catch {
|
|
return resolveLocale(undefined);
|
|
}
|
|
}
|
|
|
|
let vapidInitialized = false;
|
|
|
|
function ensureVapid(): boolean {
|
|
if (!isPushConfigured()) {
|
|
logger.warn(
|
|
"Push | VAPID keys not configured (VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY); skipping send"
|
|
);
|
|
return false;
|
|
}
|
|
if (!vapidInitialized) {
|
|
webpush.setVapidDetails(
|
|
vapidConfig.subject,
|
|
vapidConfig.publicKey,
|
|
vapidConfig.privateKey
|
|
);
|
|
vapidInitialized = true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Low-level: send a single push to one subscription. Throws on transport
|
|
* errors. Callers that persist subscriptions should handle 404/410 (dead
|
|
* endpoint) themselves — see sendPushToPlayer.
|
|
*/
|
|
export async function sendPush(
|
|
subscription: {
|
|
endpoint: string;
|
|
keys: { p256dh: string; auth: string };
|
|
},
|
|
payload: PushPayload
|
|
): Promise<void> {
|
|
if (!ensureVapid()) return;
|
|
await webpush.sendNotification(subscription, JSON.stringify(payload));
|
|
}
|
|
|
|
/**
|
|
* Send a push to every device a player has subscribed. Dead subscriptions
|
|
* (HTTP 404/410 from the push service) are pruned from storage automatically.
|
|
*
|
|
* This is the seam that real product triggers will call LATER, e.g.:
|
|
* - a match the player is in starts (src/lib/pocketbase/services/matches.ts)
|
|
* - a free-agent claim involving the player (src/lib/pocketbase/services/free-agents ...)
|
|
* - a badge is awarded to the player (src/lib/pocketbase/services/badges.ts)
|
|
* NONE of these are wired yet — this foundation pass only exposes the seam
|
|
* plus a manual "test notification" path.
|
|
*
|
|
* title/body accept either a literal string (e.g. the brand name) or a
|
|
* msg`` descriptor, resolved against the recipient's own locale.
|
|
*/
|
|
export async function sendPushToPlayer(
|
|
playerId: string,
|
|
payload: LocalizedPushPayload
|
|
): Promise<{ sent: number; failed: number; pruned: number }> {
|
|
if (!ensureVapid()) return { sent: 0, failed: 0, pruned: 0 };
|
|
|
|
const locale = await resolveRecipientLocale(playerId);
|
|
const i18n = createI18n(locale);
|
|
const resolvedPayload: PushPayload = {
|
|
...payload,
|
|
title: resolvePushText(i18n, payload.title),
|
|
body: resolvePushText(i18n, payload.body),
|
|
};
|
|
|
|
const subscriptions: PushSubscriptionRecord[] =
|
|
await pbAdmin.listPushSubscriptionsByPlayer(playerId);
|
|
|
|
let sent = 0;
|
|
let failed = 0;
|
|
let pruned = 0;
|
|
|
|
await Promise.all(
|
|
subscriptions.map(async (sub) => {
|
|
try {
|
|
await sendPush(
|
|
{
|
|
endpoint: sub.endpoint,
|
|
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
|
},
|
|
resolvedPayload
|
|
);
|
|
sent++;
|
|
} catch (error: any) {
|
|
const status = error?.statusCode;
|
|
if (status === 404 || status === 410) {
|
|
// Subscription no longer valid — prune it.
|
|
try {
|
|
await pbAdmin.deletePushSubscriptionById(sub.id);
|
|
pruned++;
|
|
} catch (delError) {
|
|
logger.error("Push | Failed to prune dead subscription", delError);
|
|
}
|
|
} else {
|
|
failed++;
|
|
logger.error(
|
|
`Push | Failed to send to endpoint (status ${status ?? "?"})`,
|
|
error?.body ?? error
|
|
);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
return { sent, failed, pruned };
|
|
}
|