新增设备别名管理功能,支持设备别名的保存与解绑
This commit is contained in:
parent
71760cbb1f
commit
70d42e6792
82
app/api/family/device-binding/route.ts
Normal file
82
app/api/family/device-binding/route.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
unbindDeviceFromCaregiver,
|
||||
updateCaregiverDeviceAlias,
|
||||
} from "@/lib/monitor-data";
|
||||
import { getDeviceName } from "@/lib/panel-format";
|
||||
import { getCaregiverSession } from "@/lib/session";
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const body = (await request.json().catch(() => null)) as
|
||||
| {
|
||||
deviceUuid?: string;
|
||||
alias?: string | null;
|
||||
}
|
||||
| null;
|
||||
|
||||
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
|
||||
return NextResponse.json({ error: "请先提供目标设备。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const caregiver = await getCaregiverSession();
|
||||
|
||||
if (!caregiver) {
|
||||
return NextResponse.json({ error: "请先登录账号。" }, { status: 401 });
|
||||
}
|
||||
|
||||
const binding = await updateCaregiverDeviceAlias({
|
||||
caregiverId: caregiver.id,
|
||||
rawDeviceValue: body.deviceUuid,
|
||||
alias: typeof body.alias === "string" || body.alias === null ? body.alias : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
alias: binding.alias,
|
||||
deviceName: getDeviceName(binding.alias, binding.elderDevice.displayName),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "设备别名保存失败。",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const body = (await request.json().catch(() => null)) as
|
||||
| {
|
||||
deviceUuid?: string;
|
||||
}
|
||||
| null;
|
||||
|
||||
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
|
||||
return NextResponse.json({ error: "请先提供目标设备。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const caregiver = await getCaregiverSession();
|
||||
|
||||
if (!caregiver) {
|
||||
return NextResponse.json({ error: "请先登录账号。" }, { status: 401 });
|
||||
}
|
||||
|
||||
const result = await unbindDeviceFromCaregiver(caregiver.id, body.deviceUuid);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
deviceUuid: result.deviceUuid,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "解绑设备失败。",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { SendMessageForm } from "@/components/dashboard-actions";
|
||||
import { ManageDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
|
||||
import {
|
||||
@ -74,6 +74,12 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<div className="space-y-6">
|
||||
<ManageDeviceForm
|
||||
deviceUuid={binding.elderDevice.deviceUuid}
|
||||
initialAlias={binding.alias}
|
||||
deviceLabel={getDeviceName(binding.elderDevice.displayName)}
|
||||
/>
|
||||
|
||||
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
|
||||
|
||||
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startTransition, useState } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
|
||||
export function BindDeviceForm() {
|
||||
const router = useRouter();
|
||||
@ -196,4 +196,158 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type ManageDeviceFormProps = {
|
||||
deviceUuid: string;
|
||||
initialAlias?: string | null;
|
||||
deviceLabel: string;
|
||||
};
|
||||
|
||||
export function ManageDeviceForm({
|
||||
deviceUuid,
|
||||
initialAlias,
|
||||
deviceLabel,
|
||||
}: ManageDeviceFormProps) {
|
||||
const router = useRouter();
|
||||
const [alias, setAlias] = useState(initialAlias ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [unbinding, setUnbinding] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAlias(initialAlias ?? "");
|
||||
}, [initialAlias]);
|
||||
|
||||
async function handleSaveAlias(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setNotice("正在保存设备别名……");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/family/device-binding", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deviceUuid,
|
||||
alias,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { error?: string; alias?: string | null; deviceName?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || "设备别名保存失败。");
|
||||
}
|
||||
|
||||
setAlias(payload?.alias?.trim() || "");
|
||||
setNotice(
|
||||
payload?.alias?.trim()
|
||||
? `已将这台设备备注为“${payload.alias.trim()}”。\n`
|
||||
: "已恢复默认设备名称。\n",
|
||||
);
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
} catch (error) {
|
||||
setNotice(
|
||||
error instanceof Error ? error.message : "设备别名保存失败。",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnbind() {
|
||||
if (!window.confirm(`解绑后将不再收到“${deviceLabel}”的留言和推送提醒,确定继续吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUnbinding(true);
|
||||
setNotice("正在解绑这台设备……");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/family/device-binding", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deviceUuid,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { error?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || "解绑设备失败。",);
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push("/devices");
|
||||
router.refresh();
|
||||
});
|
||||
} catch (error) {
|
||||
setNotice(
|
||||
error instanceof Error ? error.message : "解绑设备失败。",
|
||||
);
|
||||
setUnbinding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-white/70 bg-white/88 p-5">
|
||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||
设备管理
|
||||
</p>
|
||||
<h4 className="mt-2 font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
|
||||
修改别名或解绑设备
|
||||
</h4>
|
||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
||||
设备别名会显示在设备列表、留言页和推送通知里,例如“外婆的手机”。
|
||||
</p>
|
||||
|
||||
<form className="mt-4 flex flex-col gap-3 sm:flex-row" onSubmit={handleSaveAlias}>
|
||||
<input
|
||||
value={alias}
|
||||
onChange={(event) => setAlias(event.target.value)}
|
||||
maxLength={24}
|
||||
placeholder="给这台设备起个好记的名字"
|
||||
className="h-12 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || unbinding}
|
||||
className="inline-flex h-12 min-w-32 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{saving ? "正在保存" : "保存别名"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleUnbind();
|
||||
}}
|
||||
disabled={saving || unbinding}
|
||||
className="inline-flex h-11 items-center justify-center rounded-full border border-[rgba(160,74,48,0.28)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[rgba(199,103,51,0.08)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{unbinding ? "正在解绑" : "解绑这台设备"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,61 @@ const deviceSummarySelect = {
|
||||
lastSeenAt: true,
|
||||
} as const;
|
||||
|
||||
async function loadCaregiverDeviceNameMap(
|
||||
caregiverId: string,
|
||||
elderDeviceIds: string[],
|
||||
) {
|
||||
if (elderDeviceIds.length === 0) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
const bindings = await prisma.deviceBinding.findMany({
|
||||
where: {
|
||||
caregiverId,
|
||||
elderDeviceId: {
|
||||
in: elderDeviceIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
elderDeviceId: true,
|
||||
alias: true,
|
||||
elderDevice: {
|
||||
select: {
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return new Map(
|
||||
bindings
|
||||
.map((binding) => [
|
||||
binding.elderDeviceId,
|
||||
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || "",
|
||||
] as const)
|
||||
.filter((entry) => entry[1].length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function applyCaregiverDeviceNames<
|
||||
T extends {
|
||||
elderDeviceId: string;
|
||||
elderDevice: {
|
||||
displayName: string | null;
|
||||
};
|
||||
},
|
||||
>(items: T[], deviceNameMap: Map<string, string>) {
|
||||
items.forEach((item) => {
|
||||
const resolvedName = deviceNameMap.get(item.elderDeviceId);
|
||||
|
||||
if (resolvedName) {
|
||||
item.elderDevice.displayName = resolvedName;
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function getCaregiverOverview(caregiverId: string) {
|
||||
const dashboard = await getCaregiverDashboard(caregiverId);
|
||||
|
||||
@ -36,6 +91,11 @@ export async function getCaregiverOverview(caregiverId: string) {
|
||||
where: {
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
caregiverId,
|
||||
elderDevice: {
|
||||
bindings: {
|
||||
some: { caregiverId },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
elderDevice: {
|
||||
@ -47,6 +107,15 @@ export async function getCaregiverOverview(caregiverId: string) {
|
||||
}),
|
||||
]);
|
||||
|
||||
const deviceNameMap = new Map(
|
||||
dashboard.devices
|
||||
.map((device) => [device.elderDeviceId, device.elderDevice.displayName?.trim() || ""] as const)
|
||||
.filter((entry) => entry[1].length > 0),
|
||||
);
|
||||
|
||||
applyCaregiverDeviceNames(recentIncomingMessages, deviceNameMap);
|
||||
applyCaregiverDeviceNames(recentOutgoingMessages, deviceNameMap);
|
||||
|
||||
return {
|
||||
dashboard,
|
||||
recentIncomingMessages,
|
||||
@ -87,11 +156,23 @@ export async function getCaregiverMessages(caregiverId: string) {
|
||||
where: {
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
caregiverId,
|
||||
elderDevice: {
|
||||
bindings: {
|
||||
some: { caregiverId },
|
||||
},
|
||||
},
|
||||
readAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const deviceNameMap = await loadCaregiverDeviceNameMap(
|
||||
caregiverId,
|
||||
[...new Set(messages.map((message) => message.elderDeviceId))],
|
||||
);
|
||||
|
||||
applyCaregiverDeviceNames(messages, deviceNameMap);
|
||||
|
||||
return {
|
||||
messages,
|
||||
unreadIncomingCount,
|
||||
@ -140,6 +221,9 @@ export async function getCaregiverMessageDetail(
|
||||
});
|
||||
}
|
||||
|
||||
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, [message.elderDeviceId]);
|
||||
applyCaregiverDeviceNames([message], deviceNameMap);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@ -215,6 +299,11 @@ export async function getCaregiverDeviceDetail(
|
||||
|
||||
return {
|
||||
...binding,
|
||||
elderDevice: {
|
||||
...binding.elderDevice,
|
||||
displayName:
|
||||
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || null,
|
||||
},
|
||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||
familyUnreadCount,
|
||||
elderUnreadCount,
|
||||
@ -278,6 +367,12 @@ export async function getCaregiverActivity(caregiverId: string) {
|
||||
}),
|
||||
]);
|
||||
|
||||
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, elderDeviceIds);
|
||||
|
||||
applyCaregiverDeviceNames(usageEvents, deviceNameMap);
|
||||
applyCaregiverDeviceNames(conversationTurns, deviceNameMap);
|
||||
applyCaregiverDeviceNames(toolCalls, deviceNameMap);
|
||||
|
||||
return {
|
||||
usageEvents,
|
||||
conversationTurns,
|
||||
|
||||
@ -46,6 +46,7 @@ type ToolCallInput = {
|
||||
const PUBLIC_BASE_URL = (
|
||||
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
||||
).replace(/\/+$/, "");
|
||||
const DEVICE_ALIAS_MAX_LENGTH = 24;
|
||||
|
||||
const DEVICE_UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
@ -90,6 +91,24 @@ export function extractDeviceUuid(rawValue: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeDeviceAlias(rawAlias?: string | null) {
|
||||
const alias = rawAlias?.trim() || null;
|
||||
|
||||
if (!alias) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (alias.length > DEVICE_ALIAS_MAX_LENGTH) {
|
||||
throw new Error(`设备别名请控制在 ${DEVICE_ALIAS_MAX_LENGTH} 个字以内。`);
|
||||
}
|
||||
|
||||
return alias;
|
||||
}
|
||||
|
||||
function resolveBindingDisplayName(alias?: string | null, displayName?: string | null) {
|
||||
return alias?.trim() || displayName?.trim() || null;
|
||||
}
|
||||
|
||||
export async function ensureDeviceRegistration(input: RegisterDeviceInput) {
|
||||
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
|
||||
|
||||
@ -177,6 +196,7 @@ async function migrateLegacyCaregiverDataForDevice(
|
||||
},
|
||||
select: {
|
||||
elderDeviceId: true,
|
||||
alias: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -189,10 +209,11 @@ async function migrateLegacyCaregiverDataForDevice(
|
||||
elderDeviceId: binding.elderDeviceId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
update: binding.alias ? { alias: binding.alias } : {},
|
||||
create: {
|
||||
caregiverId,
|
||||
elderDeviceId: binding.elderDeviceId,
|
||||
alias: binding.alias,
|
||||
},
|
||||
}),
|
||||
),
|
||||
@ -393,6 +414,76 @@ export async function createFamilyMessageFromCaregiver(input: {
|
||||
});
|
||||
}
|
||||
|
||||
async function findCaregiverDeviceBinding(
|
||||
caregiverId: string,
|
||||
rawDeviceValue: string,
|
||||
) {
|
||||
const deviceUuid = extractDeviceUuid(rawDeviceValue);
|
||||
|
||||
if (!deviceUuid) {
|
||||
throw new Error("设备码格式不正确,请重新扫码或粘贴。");
|
||||
}
|
||||
|
||||
const binding = await prisma.deviceBinding.findFirst({
|
||||
where: {
|
||||
caregiverId,
|
||||
elderDevice: {
|
||||
deviceUuid,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
elderDevice: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!binding) {
|
||||
throw new Error("当前家属账号还没有绑定这台设备。");
|
||||
}
|
||||
|
||||
return binding;
|
||||
}
|
||||
|
||||
export async function updateCaregiverDeviceAlias(input: {
|
||||
caregiverId: string;
|
||||
rawDeviceValue: string;
|
||||
alias?: string | null;
|
||||
}) {
|
||||
const binding = await findCaregiverDeviceBinding(
|
||||
input.caregiverId,
|
||||
input.rawDeviceValue,
|
||||
);
|
||||
const alias = normalizeDeviceAlias(input.alias);
|
||||
|
||||
return prisma.deviceBinding.update({
|
||||
where: {
|
||||
id: binding.id,
|
||||
},
|
||||
data: {
|
||||
alias,
|
||||
},
|
||||
include: {
|
||||
elderDevice: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function unbindDeviceFromCaregiver(
|
||||
caregiverId: string,
|
||||
rawDeviceValue: string,
|
||||
) {
|
||||
const binding = await findCaregiverDeviceBinding(caregiverId, rawDeviceValue);
|
||||
|
||||
await prisma.deviceBinding.delete({
|
||||
where: {
|
||||
id: binding.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deviceUuid: binding.elderDevice.deviceUuid,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordUsageEvent(input: UsageEventInput) {
|
||||
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
||||
|
||||
@ -591,6 +682,13 @@ export async function getCaregiverDashboard(caregiverId: string) {
|
||||
caregiver,
|
||||
devices: caregiver.bindings.map((binding) => ({
|
||||
...binding,
|
||||
elderDevice: {
|
||||
...binding.elderDevice,
|
||||
displayName: resolveBindingDisplayName(
|
||||
binding.alias,
|
||||
binding.elderDevice.displayName,
|
||||
),
|
||||
},
|
||||
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
|
||||
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
|
||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||
|
||||
@ -109,6 +109,9 @@ export function getMessageHref(publicId: number) {
|
||||
return `/messages/${publicId}`;
|
||||
}
|
||||
|
||||
export function getDeviceName(displayName?: string | null) {
|
||||
return displayName?.trim() || "长辈的设备";
|
||||
export function getDeviceName(
|
||||
primaryName?: string | null,
|
||||
fallbackName?: string | null,
|
||||
) {
|
||||
return primaryName?.trim() || fallbackName?.trim() || "长辈的设备";
|
||||
}
|
||||
191
lib/push.ts
191
lib/push.ts
@ -39,6 +39,11 @@ type PushDeliveryResult = PushDeliverySummary & {
|
||||
failureDetails?: PushFailureDetail[];
|
||||
};
|
||||
|
||||
type PushAttemptResult = {
|
||||
outcome: "sent" | "removed" | "failed";
|
||||
detail?: PushFailureDetail;
|
||||
};
|
||||
|
||||
const APP_BASE_URL = (
|
||||
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
||||
).replace(/\/+$/, "");
|
||||
@ -191,6 +196,66 @@ function getPushFailureDetail(subscription: StoredPushSubscription, error: unkno
|
||||
} satisfies PushFailureDetail;
|
||||
}
|
||||
|
||||
async function sendPushNotification(input: {
|
||||
subscription: StoredPushSubscription;
|
||||
payload: string;
|
||||
topic?: string;
|
||||
collectFailureDetails?: boolean;
|
||||
}) {
|
||||
const requestOptions: {
|
||||
TTL: number;
|
||||
urgency: "high";
|
||||
topic?: string;
|
||||
} = {
|
||||
TTL: 60 * 30,
|
||||
urgency: "high",
|
||||
};
|
||||
|
||||
if (input.topic && !shouldOmitTopic(input.subscription.endpoint)) {
|
||||
requestOptions.topic = input.topic;
|
||||
}
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: input.subscription.endpoint,
|
||||
expirationTime: input.subscription.expirationTime?.getTime() || null,
|
||||
keys: {
|
||||
p256dh: input.subscription.p256dh,
|
||||
auth: input.subscription.auth,
|
||||
},
|
||||
},
|
||||
input.payload,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return {
|
||||
outcome: "sent",
|
||||
} satisfies PushAttemptResult;
|
||||
} catch (error) {
|
||||
const detail = getPushFailureDetail(input.subscription, error);
|
||||
const statusCode = detail.statusCode || 0;
|
||||
|
||||
console.error("Push notification delivery failed", detail);
|
||||
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: { endpoint: input.subscription.endpoint },
|
||||
});
|
||||
|
||||
return {
|
||||
outcome: "removed",
|
||||
detail: input.collectFailureDetails ? detail : undefined,
|
||||
} satisfies PushAttemptResult;
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: "failed",
|
||||
detail: input.collectFailureDetails ? detail : undefined,
|
||||
} satisfies PushAttemptResult;
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverPushToSubscriptions(input: {
|
||||
subscriptions: StoredPushSubscription[];
|
||||
payload: string;
|
||||
@ -209,66 +274,26 @@ async function deliverPushToSubscriptions(input: {
|
||||
|
||||
ensureVapidDetails();
|
||||
|
||||
const failureDetails: PushFailureDetail[] = [];
|
||||
|
||||
const results = await Promise.all(
|
||||
input.subscriptions.map(async (subscription) => {
|
||||
try {
|
||||
const requestOptions: {
|
||||
TTL: number;
|
||||
urgency: "high";
|
||||
topic?: string;
|
||||
} = {
|
||||
TTL: 60 * 30,
|
||||
urgency: "high",
|
||||
};
|
||||
|
||||
if (input.topic && !shouldOmitTopic(subscription.endpoint)) {
|
||||
requestOptions.topic = input.topic;
|
||||
}
|
||||
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
expirationTime: subscription.expirationTime?.getTime() || null,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
input.payload,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return "sent" as const;
|
||||
} catch (error) {
|
||||
const detail = getPushFailureDetail(subscription, error);
|
||||
const statusCode = detail.statusCode || 0;
|
||||
|
||||
console.error("Push notification delivery failed", detail);
|
||||
|
||||
if (input.collectFailureDetails) {
|
||||
failureDetails.push(detail);
|
||||
}
|
||||
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: { endpoint: subscription.endpoint },
|
||||
});
|
||||
|
||||
return "removed" as const;
|
||||
}
|
||||
|
||||
return "failed" as const;
|
||||
}
|
||||
return sendPushNotification({
|
||||
subscription,
|
||||
payload: input.payload,
|
||||
topic: input.topic,
|
||||
collectFailureDetails: input.collectFailureDetails,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const failureDetails = results
|
||||
.map((result) => result.detail)
|
||||
.filter((detail): detail is PushFailureDetail => Boolean(detail));
|
||||
|
||||
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,
|
||||
sentCount: results.filter((result) => result.outcome === "sent").length,
|
||||
removedCount: results.filter((result) => result.outcome === "removed").length,
|
||||
failedCount: results.filter((result) => result.outcome === "failed").length,
|
||||
failureDetails,
|
||||
} satisfies PushDeliveryResult;
|
||||
}
|
||||
@ -308,6 +333,25 @@ export async function sendIncomingMessagePush(publicId: number) {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
endpoint: true,
|
||||
expirationTime: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
caregiver: {
|
||||
select: {
|
||||
bindings: {
|
||||
where: {
|
||||
elderDeviceId: message.elderDeviceId,
|
||||
},
|
||||
select: {
|
||||
alias: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
@ -316,21 +360,34 @@ export async function sendIncomingMessagePush(publicId: number) {
|
||||
|
||||
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.all(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const deviceName = getDeviceName(
|
||||
subscription.caregiver.bindings[0]?.alias,
|
||||
message.elderDevice.displayName,
|
||||
);
|
||||
|
||||
await deliverPushToSubscriptions({
|
||||
subscriptions,
|
||||
payload,
|
||||
topic: `msg-${message.publicId}`,
|
||||
});
|
||||
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 sendPushNotification({
|
||||
subscription: {
|
||||
endpoint: subscription.endpoint,
|
||||
expirationTime: subscription.expirationTime,
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
payload,
|
||||
topic: `msg-${message.publicId}`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendCaregiverTestPush(input: {
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "DeviceBinding"
|
||||
ADD COLUMN IF NOT EXISTS "alias" TEXT;
|
||||
@ -90,6 +90,7 @@ model DeviceBinding {
|
||||
id String @id @default(cuid())
|
||||
caregiverId String
|
||||
elderDeviceId String
|
||||
alias String?
|
||||
createdAt DateTime @default(now())
|
||||
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
|
||||
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user