Files
flxn-app/src/lib/push/index.ts
T
kyle 0a7b5fa00f
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
i18n
2026-08-08 15:29:40 -07:00

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