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 { 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 { 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 }; }