295 lines
7.0 KiB
TypeScript
295 lines
7.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;
|
|
};
|
|
};
|
|
|
|
type StoredPushSubscription = {
|
|
endpoint: string;
|
|
expirationTime: Date | null;
|
|
p256dh: string;
|
|
auth: string;
|
|
};
|
|
|
|
type PushDeliverySummary = {
|
|
targetedCount: number;
|
|
sentCount: number;
|
|
removedCount: number;
|
|
failedCount: number;
|
|
};
|
|
|
|
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(),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function deliverPushToSubscriptions(input: {
|
|
subscriptions: StoredPushSubscription[];
|
|
payload: string;
|
|
topic: string;
|
|
}) {
|
|
if (input.subscriptions.length === 0) {
|
|
return {
|
|
targetedCount: 0,
|
|
sentCount: 0,
|
|
removedCount: 0,
|
|
failedCount: 0,
|
|
} satisfies PushDeliverySummary;
|
|
}
|
|
|
|
ensureVapidDetails();
|
|
|
|
const results = await Promise.all(
|
|
input.subscriptions.map(async (subscription) => {
|
|
try {
|
|
await webpush.sendNotification(
|
|
{
|
|
endpoint: subscription.endpoint,
|
|
expirationTime: subscription.expirationTime?.getTime() || null,
|
|
keys: {
|
|
p256dh: subscription.p256dh,
|
|
auth: subscription.auth,
|
|
},
|
|
},
|
|
input.payload,
|
|
{
|
|
TTL: 60 * 30,
|
|
urgency: "high",
|
|
topic: input.topic,
|
|
},
|
|
);
|
|
|
|
return "sent" as const;
|
|
} 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 },
|
|
});
|
|
|
|
return "removed" as const;
|
|
}
|
|
|
|
return "failed" as const;
|
|
}
|
|
}),
|
|
);
|
|
|
|
return {
|
|
targetedCount: input.subscriptions.length,
|
|
sentCount: results.filter((result) => result === "sent").length,
|
|
removedCount: results.filter((result) => result === "removed").length,
|
|
failedCount: results.filter((result) => result === "failed").length,
|
|
} satisfies PushDeliverySummary;
|
|
}
|
|
|
|
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 deliverPushToSubscriptions({
|
|
subscriptions,
|
|
payload,
|
|
topic: `msg-${message.publicId}`,
|
|
});
|
|
}
|
|
|
|
export async function sendCaregiverTestPush(input: {
|
|
caregiverId: string;
|
|
caregiverLabel: string;
|
|
}) {
|
|
if (!hasPushConfiguration()) {
|
|
throw new Error("当前环境还没有配置推送服务。",
|
|
);
|
|
}
|
|
|
|
const subscriptions = await prisma.pushSubscription.findMany({
|
|
where: {
|
|
caregiverId: input.caregiverId,
|
|
},
|
|
select: {
|
|
endpoint: true,
|
|
expirationTime: true,
|
|
p256dh: true,
|
|
auth: true,
|
|
},
|
|
});
|
|
|
|
const payload = JSON.stringify({
|
|
title: "安智伴推送测试",
|
|
body: `${input.caregiverLabel} 账号下的通知链路工作正常。`,
|
|
url: absoluteUrl("/settings"),
|
|
icon: absoluteUrl("/pwa/icon-192x192.png"),
|
|
badge: absoluteUrl("/pwa/badge-96x96.png"),
|
|
tag: `push-test-${input.caregiverId}`,
|
|
});
|
|
|
|
return deliverPushToSubscriptions({
|
|
subscriptions,
|
|
payload,
|
|
topic: `test-${input.caregiverId.slice(0, 20)}`,
|
|
});
|
|
} |