- 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
82 lines
2.4 KiB
TypeScript
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)));
|
|
}
|
|
}
|