Files
famapp/src/modules/_core/push.ts
T
ginnoir 19308be768 feat: add dev startup script, fix push notifications, and scope test sends to device
- pnpm dev:local/dev:reset: orchestrate DB container, migrations, seed, and Next.js dev server in one command; Caddy snippet + docs for HTTPS via dev.ginnoir.com
- Fix dev login on HTTPS: set both authjs.session-token and __Secure-authjs.session-token so Auth.js finds the session regardless of cookie name resolution
- Suppress hydration mismatch on <html> caused by pre-paint script changing data-nav before React hydrates
- VAPID startup warning if keys not configured; remove dead NEXT_PUBLIC_VAPID_PUBLIC_KEY var
- PushOptIn: hydrate subscription state on mount; reuse existing subscription on iOS to avoid redundant prompts
- sendPushToEndpoint: new function to send to a single device endpoint
- sendTestNotification: scoped to the calling device's endpoint (ownership-verified) instead of all user subscriptions
2026-06-01 17:45:20 -05:00

82 lines
2.4 KiB
TypeScript

import webPush from "web-push";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db";
import logger from "@/lib/logger";
import { pushSubscriptions } from "./schema";
function ensureVapidConfigured() {
const subject = process.env["VAPID_SUBJECT"];
const publicKey = process.env["VAPID_PUBLIC_KEY"];
const privateKey = process.env["VAPID_PRIVATE_KEY"];
if (!subject || !publicKey || !privateKey) {
throw new Error("VAPID_SUBJECT, VAPID_PUBLIC_KEY, and VAPID_PRIVATE_KEY must be set");
}
webPush.setVapidDetails(subject, publicKey, privateKey);
}
export async function sendPushToEndpoint(
endpoint: string,
payload: { title: string; body: string; url?: string },
) {
ensureVapidConfigured();
const [sub] = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.endpoint, endpoint))
.limit(1);
if (!sub) return;
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }),
);
} catch (err) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 404 || status === 410) {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
} else {
logger.error({ err }, "push delivery failed");
}
}
}
export async function sendPush(
userId: string,
payload: { title: string; body: string; url?: string },
) {
ensureVapidConfigured();
const subs = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.userId, userId));
if (subs.length === 0) return;
const staleIds: string[] = [];
await Promise.allSettled(
subs.map(async (sub) => {
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }),
);
} catch (err) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 404 || status === 410) {
staleIds.push(sub.id);
} else {
logger.error({ err }, "push delivery failed");
}
}
}),
);
if (staleIds.length > 0) {
await db
.delete(pushSubscriptions)
.where(and(eq(pushSubscriptions.userId, userId), inArray(pushSubscriptions.id, staleIds)));
}
}