208 lines
5.0 KiB
TypeScript

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