social overhaul
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import webpush from "web-push";
|
||||
import { vapidConfig, isPushConfigured } from "@/lib/config";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export async function sendPushToPlayer(
|
||||
playerId: string,
|
||||
payload: PushPayload
|
||||
): Promise<{ sent: number; failed: number; pruned: number }> {
|
||||
if (!ensureVapid()) return { sent: 0, failed: 0, pruned: 0 };
|
||||
|
||||
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 },
|
||||
},
|
||||
payload
|
||||
);
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user