import { MessageDirection } from "@prisma/client"; import webpush from "web-push"; import { getDeviceName, truncateText } from "@/lib/panel-format"; import { prisma } from "@/lib/prisma"; type SerializablePushSubscription = { endpoint: string; expirationTime?: number | null; keys?: { p256dh?: string; auth?: string; }; }; const APP_BASE_URL = ( process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net" ).replace(/\/+$/, ""); let vapidReady = false; function hasPushConfiguration() { return Boolean( process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT, ); } function ensureVapidDetails() { if (!hasPushConfiguration()) { throw new Error("VAPID 配置尚未完成,暂时不能发送推送通知。"); } if (vapidReady) { return; } webpush.setVapidDetails( process.env.VAPID_SUBJECT!, process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!, ); vapidReady = true; } function normalizeSubscription(input: SerializablePushSubscription) { const endpoint = input.endpoint?.trim(); const p256dh = input.keys?.p256dh?.trim(); const auth = input.keys?.auth?.trim(); if (!endpoint || !p256dh || !auth) { throw new Error("推送订阅信息不完整。"); } return { endpoint, p256dh, auth, expirationTime: typeof input.expirationTime === "number" ? new Date(input.expirationTime) : null, }; } function absoluteUrl(path: string) { if (/^https?:\/\//i.test(path)) { return path; } return `${APP_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`; } export function getPushPublicKey() { return process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ""; } export function isPushAvailable() { return hasPushConfiguration(); } export async function savePushSubscription(input: { caregiverId: string; subscription: SerializablePushSubscription; userAgent?: string | null; }) { const subscription = normalizeSubscription(input.subscription); return prisma.pushSubscription.upsert({ where: { endpoint: subscription.endpoint }, update: { caregiverId: input.caregiverId, p256dh: subscription.p256dh, auth: subscription.auth, expirationTime: subscription.expirationTime, userAgent: input.userAgent?.trim() || null, }, create: { caregiverId: input.caregiverId, endpoint: subscription.endpoint, p256dh: subscription.p256dh, auth: subscription.auth, expirationTime: subscription.expirationTime, userAgent: input.userAgent?.trim() || null, }, }); } export async function removePushSubscription(input: { caregiverId: string; endpoint: string; }) { await prisma.pushSubscription.deleteMany({ where: { caregiverId: input.caregiverId, endpoint: input.endpoint.trim(), }, }); } export async function sendIncomingMessagePush(publicId: number) { if (!hasPushConfiguration()) { return; } const message = await prisma.familyMessage.findUnique({ where: { publicId }, select: { publicId: true, content: true, direction: true, elderDeviceId: true, elderDevice: { select: { deviceUuid: true, displayName: true, }, }, }, }); if (!message || message.direction !== MessageDirection.ELDER_TO_FAMILY) { return; } const subscriptions = await prisma.pushSubscription.findMany({ where: { caregiver: { bindings: { some: { elderDeviceId: message.elderDeviceId, }, }, }, }, }); if (subscriptions.length === 0) { return; } ensureVapidDetails(); const deviceName = getDeviceName(message.elderDevice.displayName); const payload = JSON.stringify({ title: `${deviceName} 发来一条新留言`, body: truncateText(message.content, 72), url: absoluteUrl(`/messages/${message.publicId}`), icon: absoluteUrl("/pwa/icon-192x192.png"), badge: absoluteUrl("/pwa/badge-96x96.png"), tag: `message-${message.publicId}`, }); await Promise.allSettled( subscriptions.map(async (subscription) => { try { await webpush.sendNotification( { endpoint: subscription.endpoint, expirationTime: subscription.expirationTime?.getTime() || null, keys: { p256dh: subscription.p256dh, auth: subscription.auth, }, }, payload, { TTL: 60 * 30, urgency: "high", topic: `msg-${message.publicId}`, }, ); } catch (error) { const statusCode = error && typeof error === "object" && "statusCode" in error ? Number((error as { statusCode?: number }).statusCode) : 0; if (statusCode === 404 || statusCode === 410) { await prisma.pushSubscription.deleteMany({ where: { endpoint: subscription.endpoint }, }); } } }), ); }