家庭守护圈:多人加入、角色权限、24 小时邀请链接、旧设备绑定自动兼容。

家庭动态:老人/家属发言、三种可见范围,家庭内部内容不会播给老人。
共同照护:家庭备忘录、任务创建、认领、完成与交接记录。
用药闭环:计划、未来 7 天提醒实例、确认服用、延后、异常反馈。
风险闭环:红橙黄蓝分级、30 分钟去重、推送、认领、处理与误报记录。
报平安:家属发起、老人语音回应、状态同步。
Web 导航升级为守护、家庭、照护、陪伴、我的。
设备接口已升级为设备令牌认证,UUID 不再单独承担认证。
This commit is contained in:
feie9456 2026-07-18 19:59:20 +08:00
parent b7579c5947
commit 747dcd359a
33 changed files with 1557 additions and 111 deletions

13
app/api/alerts/route.ts Normal file
View File

@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { updateAlert } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function PATCH(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const alert = await updateAlert(caregiver.id, String(body.alertId || ""), String(body.action || ""), body.note);
return NextResponse.json({ ok: true, alert });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "更新提醒失败。" }, { status: 400 }); }
}

View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { createCareTask, updateCareTask } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const task = await createCareTask(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, task });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "创建任务失败。" }, { status: 400 }); }
}
export async function PATCH(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const task = await updateCareTask(caregiver.id, String(body.taskId || ""), String(body.action || ""), body.note);
return NextResponse.json({ ok: true, task });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "更新任务失败。" }, { status: 400 }); }
}

View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { createCheckIn } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const checkIn = await createCheckIn(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, checkIn });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "发起报平安失败。" }, { status: 400 }); }
}

View File

@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { requireDeviceAuth } from "@/lib/device-auth";
import { getDeviceAgenda } from "@/lib/family-guard";
export async function GET(request: Request) {
const deviceUuid = new URL(request.url).searchParams.get("deviceUuid") || "";
try {
await requireDeviceAuth(request, deviceUuid);
const agenda = await getDeviceAgenda(deviceUuid);
return NextResponse.json({
circleName: agenda.circle?.name || null,
posts: agenda.posts.map((post) => ({ id: post.id, content: post.content, authorName: post.author?.nickname || post.author?.username || "家里人", createdAt: post.createdAt })),
memos: agenda.memos.map((memo) => ({ id: memo.id, title: memo.title, content: memo.content, priority: memo.priority })),
doses: agenda.doses.map((dose) => ({ id: dose.id, scheduledFor: dose.scheduledFor, status: dose.status, name: dose.medicationPlan.name, dosage: dose.medicationPlan.dosage, instructions: dose.medicationPlan.instructions })),
checkIns: agenda.checkIns,
});
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "读取今日照护事项失败。" }, { status: 401 }); }
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { requireDeviceAuth } from "@/lib/device-auth";
import { createSafetyAlertForDevice } from "@/lib/family-guard";
import { sendGuardAlertPush } from "@/lib/push";
export async function POST(request: Request) {
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const deviceUuid = typeof body.deviceUuid === "string" ? body.deviceUuid : "";
try {
await requireDeviceAuth(request, deviceUuid);
const result = await createSafetyAlertForDevice(deviceUuid, body);
if (!result.deduplicated) void sendGuardAlertPush(result.alert.id).catch(() => undefined);
return NextResponse.json({ ok: true, alertId: result.alert.id, deduplicated: result.deduplicated });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "创建风险提醒失败。" }, { status: 400 }); }
}

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/device/agenda/route";

View File

@ -2,6 +2,7 @@ import { ConversationRole } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordConversationTurns } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toConversationRole(rawRole: unknown) {
switch (rawRole) {
@ -55,6 +56,7 @@ export async function POST(request: Request) {
}
try {
await requireDeviceAuth(request, body.deviceUuid);
const result = await recordConversationTurns({
deviceUuid: body.deviceUuid,
sessionId: typeof body.sessionId === "string" ? body.sessionId : undefined,
@ -71,4 +73,4 @@ export async function POST(request: Request) {
{ status: 400 },
);
}
}
}

View File

@ -0,0 +1,28 @@
import { CheckInStatus, MedicationDoseStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireDeviceAuth } from "@/lib/device-auth";
import { respondCheckIn, updateMedicationDose } from "@/lib/family-guard";
export async function POST(request: Request) {
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const deviceUuid = typeof body.deviceUuid === "string" ? body.deviceUuid : "";
try {
await requireDeviceAuth(request, deviceUuid);
if (body.type === "MEDICATION") {
const allowed = Object.values(MedicationDoseStatus);
const status = allowed.includes(body.status as MedicationDoseStatus) ? body.status as MedicationDoseStatus : MedicationDoseStatus.NEEDS_FAMILY_CONFIRMATION;
const dose = await updateMedicationDose(deviceUuid, String(body.doseId || ""), status, body.note, Number(body.snoozeMinutes || 10));
return NextResponse.json({ ok: true, dose });
}
if (body.type === "CHECK_IN") {
const status = body.status === CheckInStatus.SAFE
? CheckInStatus.SAFE
: body.status === CheckInStatus.DECLINED
? CheckInStatus.DECLINED
: CheckInStatus.NEEDS_CONTACT;
const checkIn = await respondCheckIn(deviceUuid, String(body.checkInId || ""), status, body.responseText);
return NextResponse.json({ ok: true, checkIn });
}
throw new Error("不支持的设备事件类型。");
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "保存照护反馈失败。" }, { status: 400 }); }
}

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { markFamilyMessagesAsRead } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
@ -24,6 +25,7 @@ export async function POST(request: Request) {
: undefined;
try {
await requireDeviceAuth(request, body.deviceUuid);
await markFamilyMessagesAsRead(body.deviceUuid, messageIds);
return NextResponse.json({ ok: true });
@ -36,4 +38,4 @@ export async function POST(request: Request) {
{ status: 400 },
);
}
}
}

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { listFamilyMessagesForDevice } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
export async function GET(request: NextRequest) {
const rawDeviceValue =
@ -20,6 +21,7 @@ export async function GET(request: NextRequest) {
}
try {
await requireDeviceAuth(request, rawDeviceValue);
const data = await listFamilyMessagesForDevice(rawDeviceValue, take);
return NextResponse.json({
@ -45,4 +47,4 @@ export async function GET(request: NextRequest) {
{ status: 400 },
);
}
}
}

View File

@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { createBindUrl, ensureDeviceRegistration } from "@/lib/monitor-data";
import { createDeviceToken, hashDeviceToken } from "@/lib/device-auth";
import { prisma } from "@/lib/prisma";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
@ -8,6 +10,7 @@ export async function POST(request: Request) {
deviceUuid?: string;
displayName?: string;
appVersion?: string;
deviceToken?: string;
}
| null;
@ -26,10 +29,20 @@ export async function POST(request: Request) {
appVersion:
typeof body.appVersion === "string" ? body.appVersion : undefined,
});
const suppliedToken = typeof body.deviceToken === "string" ? body.deviceToken.trim() : "";
const tokenMatches = suppliedToken && device.deviceTokenHash === hashDeviceToken(suppliedToken);
if (device.deviceTokenHash && !tokenMatches) {
return NextResponse.json({ error: "设备认证失败,不能覆盖已注册设备。" }, { status: 401 });
}
const deviceToken = tokenMatches ? suppliedToken : createDeviceToken();
if (!device.deviceTokenHash) {
await prisma.elderDevice.update({ where: { id: device.id }, data: { deviceTokenHash: hashDeviceToken(deviceToken) } });
}
return NextResponse.json({
deviceUuid: device.deviceUuid,
bindUrl: createBindUrl(device.deviceUuid),
deviceToken,
});
} catch (error) {
return NextResponse.json(
@ -40,4 +53,4 @@ export async function POST(request: Request) {
{ status: 500 },
);
}
}
}

View File

@ -2,6 +2,7 @@ import { ToolCallStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordToolCall } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toToolCallStatus(rawStatus: unknown) {
return rawStatus === ToolCallStatus.FAILURE
@ -49,6 +50,7 @@ export async function POST(request: Request) {
}
try {
await requireDeviceAuth(request, body.deviceUuid);
const log = await recordToolCall({
deviceUuid: body.deviceUuid,
toolName: body.toolName,
@ -70,4 +72,4 @@ export async function POST(request: Request) {
{ status: 400 },
);
}
}
}

View File

@ -2,6 +2,7 @@ import { UsageEventType } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordUsageEvent } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toUsageEventType(rawType: unknown) {
switch (rawType) {
@ -46,6 +47,7 @@ export async function POST(request: Request) {
}
try {
await requireDeviceAuth(request, body.deviceUuid);
await recordUsageEvent({
deviceUuid: body.deviceUuid,
eventType: toUsageEventType(body.eventType),
@ -62,4 +64,4 @@ export async function POST(request: Request) {
{ status: 400 },
);
}
}
}

View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { createFamilyInvitation } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const result = await createFamilyInvitation(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, ...result });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "创建邀请失败。" }, { status: 400 }); }
}

View File

@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { acceptFamilyInvitation } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录后接受邀请。" }, { status: 401 });
const body = await request.json().catch(() => ({})) as { token?: string };
const circle = await acceptFamilyInvitation(caregiver.id, body.token || "");
return NextResponse.json({ ok: true, circleId: circle.id });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "加入家庭失败。" }, { status: 400 }); }
}

View File

@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { createFamilyPost } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const body = await request.json().catch(() => ({}));
const post = await createFamilyPost(caregiver.id, body);
return NextResponse.json({ ok: true, post });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "发布失败。" }, { status: 400 }); }
}

View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { createMedicationPlan } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const plan = await createMedicationPlan(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, plan });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "创建用药计划失败。" }, { status: 400 }); }
}

12
app/api/memos/route.ts Normal file
View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { createFamilyMemo } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const memo = await createFamilyMemo(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, memo });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "保存失败。" }, { status: 400 }); }
}

15
app/care/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { MedicationForm, MemoForm, TaskActions, TaskForm } from "@/components/family-guard-actions";
import { PanelShell } from "@/components/panel-shell";
import { requireFamilyWorkspace } from "@/lib/family-page";
import { formatDateTime } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function CarePage({ searchParams }: PageProps<"/care">) {
const caregiver = await requireCaregiverSession("/care"); const workspace = await requireFamilyWorkspace(caregiver.id); const tab = String((await searchParams).tab || "tasks"); const uuid = workspace.elderProfile.elderDevice.deviceUuid;
return <PanelShell currentPath="/care" title="共同照护" description="家人分工、老人确认、结果共享" caregiverLabel={getCaregiverDisplayName(caregiver)}>
<div className="segment"><a className={tab === "tasks" ? "active" : ""} href="/care?tab=tasks"></a><a className={tab === "medications" ? "active" : ""} href="/care?tab=medications"></a><a className={tab === "memos" ? "active" : ""} href="/care?tab=memos"></a></div>
{tab === "medications" ? <><MedicationForm deviceUuid={uuid} /><section><div className="section-title"><h2></h2><span></span></div><div className="card">{workspace.medicationPlans.length ? workspace.medicationPlans.map((plan) => <article className="timeline-item" key={plan.id}><div className="timeline-top">{plan.name}<span className="tag">{plan.active ? "进行中" : "已暂停"}</span></div><p className="timeline-body"> {plan.dosage} · {JSON.parse(plan.timesJson).join("、")}{plan.instructions ? ` · ${plan.instructions}` : ""}</p><p className="row-meta">{plan.source === "PRESCRIPTION" ? "医生处方" : plan.source === "PACKAGE_LABEL" ? "药盒标签" : "家属录入"}</p></article>) : <div className="empty"></div>}</div></section></> : tab === "memos" ? <><MemoForm deviceUuid={uuid} /><section><div className="section-title"><h2></h2></div><div className="card">{workspace.memos.length ? workspace.memos.map((memo) => <article className="timeline-item" key={memo.id}><div className="timeline-top">{memo.title}<span className="tag">{memo.priority === "PINNED" ? "长期置顶" : memo.priority === "IMPORTANT" ? "重要" : "普通"}</span></div><p className="timeline-body">{memo.content}</p><p className="row-meta">{memo.visibility === "FAMILY_ONLY" ? "仅家人可见" : "可由数字人告诉长辈"}</p></article>) : <div className="empty"></div>}</div></section></> : <><TaskForm deviceUuid={uuid} /><section><div className="section-title"><h2></h2></div><div className="card">{workspace.tasks.length ? workspace.tasks.map((task) => <article className="timeline-item" key={task.id}><div className="timeline-top">{task.title}<span className="timeline-time">{task.dueAt ? formatDateTime(task.dueAt) : "未设期限"}</span></div><p className="timeline-body">{task.description || "等待家人认领"}</p><p className="row-meta">{task.assignedTo ? `负责人:${task.assignedTo.nickname || task.assignedTo.username || "家人"}` : "尚未认领"}</p><TaskActions taskId={task.id} status={task.status} /></article>) : <div className="empty"></div>}</div></section></>}
</PanelShell>;
}

8
app/companion/page.tsx Normal file
View File

@ -0,0 +1,8 @@
import Link from "next/link";
import { BookHeart, MessageCircle, MessagesSquare } from "lucide-react";
import { PanelShell } from "@/components/panel-shell";
import { requireFamilyWorkspace as getFamilyWorkspace } from "@/lib/family-page";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export default async function CompanionPage() { const caregiver = await requireCaregiverSession("/companion"); const workspace = await getFamilyWorkspace(caregiver.id); return <PanelShell currentPath="/companion" title="温暖陪伴" description="留言、回忆和每一次认真回应" caregiverLabel={getCaregiverDisplayName(caregiver)}><div className="card"><Link className="list-row" href="/messages"><MessageCircle /><span className="row-main"><span className="row-title"></span><span className="row-meta"></span></span></Link><Link className="list-row" href="/activity"><MessagesSquare /><span className="row-main"><span className="row-title"></span><span className="row-meta"></span></span></Link><div className="list-row"><BookHeart /><span className="row-main"><span className="row-title"></span><span className="row-meta"></span></span></div></div><section className="card card-pad"><h2 style={{ marginTop: 0 }}></h2><p className="timeline-body"> {workspace.posts.length} {workspace.memos.length} </p></section></PanelShell>; }

8
app/family/join/page.tsx Normal file
View File

@ -0,0 +1,8 @@
import { JoinFamilyButton } from "@/components/family-guard-actions";
import { requireCaregiverSession } from "@/lib/page-auth";
export default async function JoinFamilyPage({ searchParams }: PageProps<"/family/join">) {
const token = String((await searchParams).token || "");
await requireCaregiverSession(`/family/join?token=${encodeURIComponent(token)}`);
return <main className="app-frame"><div className="app-content app-stack"><section className="card card-pad" style={{ marginTop: 48 }}><h1 style={{ margin: 0 }}></h1><p className="timeline-body"></p><JoinFamilyButton token={token} /></section></div></main>;
}

18
app/family/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import { FamilyPostForm, InviteMemberForm } from "@/components/family-guard-actions";
import { PanelShell } from "@/components/panel-shell";
import { requireFamilyWorkspace } from "@/lib/family-page";
import { formatDateTime } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function FamilyPage() {
const caregiver = await requireCaregiverSession("/family"); const workspace = await requireFamilyWorkspace(caregiver.id); const deviceUuid = workspace.elderProfile.elderDevice.deviceUuid;
const roleLabel = { ADMIN: "管理员", CAREGIVER: "照护成员", CARING: "关怀成员" } as const;
return <PanelShell currentPath="/family" title={workspace.name} description={`${workspace.memberships.length} 位家人共同参与`} caregiverLabel={getCaregiverDisplayName(caregiver)}>
<FamilyPostForm deviceUuid={deviceUuid} />
<section><div className="section-title"><h2></h2></div><div className="card">{workspace.posts.length ? workspace.posts.map((post) => <article key={post.id} className="timeline-item"><div className="timeline-top">{post.elderAuthored ? `${workspace.elderProfile.preferredName}通过安智伴说` : post.author?.nickname || post.author?.username || "家里人"}<span className="timeline-time">{formatDateTime(post.createdAt)}</span></div><p className="timeline-body">{post.content}</p><span className="tag">{post.visibility === "FAMILY_ONLY" ? "仅家人" : post.visibility === "ELDER_ONLY" ? "只给长辈" : "全家可见"}</span></article>) : <div className="empty"></div>}</div></section>
<section><div className="section-title"><h2></h2></div><div className="card">{workspace.memberships.map((member) => <div key={member.id} className="list-row"><span className="avatar">{(member.caregiver.nickname || member.caregiver.username || "家").slice(-1)}</span><span className="row-main"><span className="row-title">{member.caregiver.nickname || member.caregiver.username || "家庭成员"}{member.relationLabel ? ` · ${member.relationLabel}` : ""}</span><span className="row-meta">{roleLabel[member.role]}{member.dutyOrder ? ` · 值守顺序 ${member.dutyOrder}` : ""}</span></span></div>)}</div></section>
<section><div className="section-title"><h2></h2><span> 24 </span></div><InviteMemberForm deviceUuid={deviceUuid} /></section>
</PanelShell>;
}

23
app/guard/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { AlertTriangle, CheckCircle2, HeartPulse, PhoneCall } from "lucide-react";
import { AlertActions, CheckInButton } from "@/components/family-guard-actions";
import { PanelShell } from "@/components/panel-shell";
import { requireFamilyWorkspace } from "@/lib/family-page";
import { formatDateTime } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function GuardPage() {
const caregiver = await requireCaregiverSession("/guard"); const workspace = await requireFamilyWorkspace(caregiver.id);
const openAlerts = workspace.alerts.filter((a) => a.status === "OPEN" || a.status === "ACKNOWLEDGED");
const todayDoses = workspace.medicationPlans.flatMap((p) => p.doses).filter((d) => d.scheduledFor.toDateString() === new Date().toDateString());
const doneDoses = todayDoses.filter((d) => d.status === "TAKEN").length;
const openTasks = workspace.tasks.filter((t) => t.status !== "COMPLETED" && t.status !== "CANCELLED");
const pendingCheckIns = workspace.checkIns.filter((c) => c.status === "PENDING");
return <PanelShell currentPath="/guard" title="家庭守护" description={`${workspace.elderProfile.preferredName} · 风险线索不是医疗诊断`} caregiverLabel={getCaregiverDisplayName(caregiver)}>
<section className="stats" style={{ gridTemplateColumns: "repeat(4,1fr)" }}><div className="stat accent"><strong>{openAlerts.length}</strong><span></span></div><div className="stat"><strong>{doneDoses}/{todayDoses.length}</strong><span></span></div><div className="stat"><strong>{openTasks.length}</strong><span></span></div><div className="stat"><strong>{pendingCheckIns.length}</strong><span></span></div></section>
<CheckInButton deviceUuid={workspace.elderProfile.elderDevice.deviceUuid} />
<section><div className="section-title"><h2></h2><span></span></div><div className="card">{workspace.alerts.length ? workspace.alerts.map((alert) => <article id={`alert-${alert.id}`} key={alert.id} className="timeline-item"><div className="timeline-top"><span style={{ color: alert.level === "RED" ? "#b33a2b" : "var(--accent)" }}><AlertTriangle size={15} style={{ verticalAlign: -3, marginRight: 5 }} />{alert.title}</span><span className="timeline-time">{formatDateTime(alert.createdAt)}</span></div><p className="timeline-body">{alert.summary}</p><p className="row-meta">{alert.claimedBy ? `已由 ${alert.claimedBy.nickname || alert.claimedBy.username || "家人"} 接手` : "尚未认领"}</p><AlertActions alertId={alert.id} status={alert.status} /></article>) : <div className="empty"><CheckCircle2 size={26} /><br /></div>}</div></section>
<section><div className="section-title"><h2></h2></div><div className="card"><div className="timeline-item"><div className="timeline-top"><HeartPulse size={15} />线<span className="timeline-time">{formatDateTime(workspace.elderProfile.elderDevice.lastSeenAt)}</span></div><p className="timeline-body">线</p></div><div className="timeline-item"><div className="timeline-top"><PhoneCall size={15} /></div><p className="timeline-body">{workspace.memberships.filter((m) => m.emergencyPush).map((m) => m.caregiver.nickname || m.caregiver.username || "家人").join("、") || "尚未设置"}</p></div></div></section>
</PanelShell>;
}

View File

@ -1,86 +1,2 @@
import Link from "next/link";
import { ChevronRight, PenLine, ScanLine } from "lucide-react";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverOverview } from "@/lib/caregiver-panel";
import { formatDateTime, getDeviceName, getMessageHref, truncateText } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
function greeting() {
const hour = new Date().getHours();
if (hour < 11) return "早上好";
if (hour < 14) return "中午好";
if (hour < 18) return "下午好";
return "晚上好";
}
export default async function Home() {
const caregiver = await requireCaregiverSession("/");
const { dashboard, recentIncomingMessages } = await getCaregiverOverview(caregiver.id);
const name = getCaregiverDisplayName(dashboard.caregiver);
const unreadIn = dashboard.devices.reduce((n, d) => n + d.elderUnreadCount, 0);
const unreadOut = dashboard.devices.reduce((n, d) => n + d.familyUnreadCount, 0);
const conversations = dashboard.devices.reduce((n, d) => n + d.elderDevice._count.conversationTurns, 0);
const date = new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", weekday: "short" }).format(new Date());
return (
<PanelShell
currentPath="/"
title={`${greeting()}${name}`}
description={`${date} · ${dashboard.devices.length} 台设备已连接`}
caregiverLabel={name}
unreadCount={unreadIn}
>
<section className="card">
{dashboard.devices.length ? dashboard.devices.map((binding) => (
<Link key={binding.id} href={`/devices/${binding.elderDevice.deviceUuid}`} className="list-row" style={{ alignItems: "center" }}>
<span className={`dot ${binding.elderDevice.lastSeenAt ? "online" : "offline"}`} style={{ marginTop: 0 }} />
<span className="row-title" style={{ flex: 1 }}>{getDeviceName(binding.alias, binding.elderDevice.displayName)}</span>
<span className="row-meta" style={{ marginTop: 0 }}>{binding.elderDevice.lastSeenAt ? "在线" : "未连接"}</span>
<ChevronRight size={14} color="var(--muted-light)" />
</Link>
)) : <div className="empty"></div>}
</section>
<section className="stats" style={{ gridTemplateColumns: "repeat(4,1fr)" }}>
<Link href="/messages" className="stat accent"><strong>{unreadIn}</strong><span></span></Link>
<Link href="/messages" className="stat"><strong>{unreadOut}</strong><span></span></Link>
<Link href="/activity" className="stat"><strong>{conversations}</strong><span></span></Link>
<Link href="/devices" className="stat"><strong>{dashboard.devices.length}</strong><span></span></Link>
</section>
<div className="button-row">
<Link href={dashboard.devices[0] ? `/devices/${dashboard.devices[0].elderDevice.deviceUuid}` : "/devices"} className="btn primary" style={{ flex: 1 }}>
<PenLine size={15} />
</Link>
<Link href="/scan" className="btn" style={{ flex: 1 }}><ScanLine size={15} /></Link>
</div>
<section>
<div className="section-title"><h2></h2><Link href="/messages"></Link></div>
<div className="card">
{recentIncomingMessages.length ? recentIncomingMessages.slice(0, 3).map((message) => (
<Link key={message.id} href={getMessageHref(message.publicId)} className="list-row">
<span className="dot" />
<span className="row-main">
<span className="row-title">{truncateText(message.content, 62)}</span>
<span className="row-meta">{message.importance !== "NORMAL" ? <span className="tag"></span> : null}{getDeviceName(message.elderDevice.displayName)} · {formatDateTime(message.createdAt)}</span>
</span>
</Link>
)) : <div className="empty"></div>}
</div>
</section>
<section>
<div className="section-title"><h2></h2><Link href="/activity"></Link></div>
<div className="card">
<div className="timeline-item"><div className="timeline-top"><span className="timeline-time"></span></div><p className="timeline-body">{dashboard.devices.length} </p></div>
<div className="timeline-item"><div className="timeline-top"><span className="timeline-time"></span></div><p className="timeline-body"> {conversations} </p></div>
</div>
</section>
</PanelShell>
);
}
import { redirect } from "next/navigation";
export default function Home() { redirect("/guard"); }

View File

@ -0,0 +1,73 @@
"use client";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
async function api(path: string, method: string, body: Record<string, unknown>) {
const response = await fetch(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "操作失败,请稍后再试。");
return payload;
}
function Notice({ text }: { text: string }) { return text ? <p style={{ margin: "8px 0 0", color: "var(--success)", fontSize: 12 }}>{text}</p> : null; }
export function FamilyPostForm({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter(); const [notice, setNotice] = useState(""); const [busy, setBusy] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); setBusy(true); const form = new FormData(event.currentTarget);
try { await api("/api/family-feed", "POST", { deviceUuid, content: form.get("content"), visibility: form.get("visibility") }); event.currentTarget.reset(); setNotice("已发布到家庭动态。"); router.refresh(); }
catch (e) { setNotice(e instanceof Error ? e.message : "发布失败。"); } finally { setBusy(false); }
}
return <form className="card card-pad" onSubmit={submit}><textarea className="field" name="content" required maxLength={1000} placeholder="分享一句问候,或和家人同步今天的情况……" /><div className="button-row" style={{ marginTop: 8 }}><select className="field" name="visibility" defaultValue="FAMILY_AND_ELDER"><option value="FAMILY_AND_ELDER"></option><option value="ELDER_ONLY"></option><option value="FAMILY_ONLY"></option></select><button className="btn primary" disabled={busy}>{busy ? "发布中" : "发布"}</button></div><Notice text={notice} /></form>;
}
export function MemoForm({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter(); const [notice, setNotice] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = new FormData(event.currentTarget); try { await api("/api/memos", "POST", { deviceUuid, title: form.get("title"), content: form.get("content"), priority: form.get("priority"), visibility: form.get("visibility") }); event.currentTarget.reset(); setNotice("家庭备忘已保存。"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "保存失败。"); } }
return <form className="card card-pad" onSubmit={submit}><input className="field" name="title" required maxLength={80} placeholder="备忘标题,如:医保卡位置" /><textarea className="field" style={{ marginTop: 8 }} name="content" required maxLength={1000} placeholder="写下家人需要共同记住的信息" /><div className="button-row" style={{ marginTop: 8 }}><select className="field" name="priority"><option value="NORMAL"></option><option value="IMPORTANT"></option><option value="PINNED"></option></select><select className="field" name="visibility"><option value="FAMILY_AND_ELDER"></option><option value="FAMILY_ONLY"></option></select><button className="btn primary"></button></div><Notice text={notice} /></form>;
}
export function TaskForm({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter(); const [notice, setNotice] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = new FormData(event.currentTarget); try { await api("/api/care-tasks", "POST", { deviceUuid, title: form.get("title"), description: form.get("description"), dueAt: form.get("dueAt") }); event.currentTarget.reset(); setNotice("照护任务已创建。"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "创建失败。"); } }
return <form className="card card-pad" onSubmit={submit}><input className="field" name="title" required maxLength={100} placeholder="任务,如:陪爷爷周三复诊" /><input className="field" style={{ marginTop: 8 }} name="description" maxLength={500} placeholder="补充说明(可选)" /><div className="button-row" style={{ marginTop: 8 }}><input className="field" name="dueAt" type="datetime-local" /><button className="btn primary"></button></div><Notice text={notice} /></form>;
}
export function TaskActions({ taskId, status }: { taskId: string; status: string }) {
const router = useRouter(); const [busy, setBusy] = useState(false);
async function act(action: string) { setBusy(true); try { let note: string | undefined; if (action === "complete") note = window.prompt("补充完成说明(可留空)") || undefined; await api("/api/care-tasks", "PATCH", { taskId, action, note }); router.refresh(); } finally { setBusy(false); } }
if (status === "COMPLETED") return <span className="tag"></span>;
return <div className="button-row"><button className="btn small" disabled={busy} onClick={() => void act("claim")}>{status === "CLAIMED" ? "接手人" : "我来处理"}</button><button className="btn primary small" disabled={busy} onClick={() => void act("complete")}></button></div>;
}
export function MedicationForm({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter(); const [notice, setNotice] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = new FormData(event.currentTarget); try { await api("/api/medications", "POST", { deviceUuid, name: form.get("name"), dosage: form.get("dosage"), times: form.get("times"), instructions: form.get("instructions"), source: form.get("source"), stockCount: Number(form.get("stockCount") || 0) || undefined }); event.currentTarget.reset(); setNotice("未来 7 天的用药提醒已建立。"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "创建失败。"); } }
return <form className="card card-pad" onSubmit={submit}><div className="button-row"><input className="field" name="name" required placeholder="药品名称" /><input className="field" name="dosage" required placeholder="单次用量1片" /></div><div className="button-row" style={{ marginTop: 8 }}><input className="field" name="times" required placeholder="时间08:00,20:00" /><select className="field" name="source"><option value="PRESCRIPTION"></option><option value="PACKAGE_LABEL"></option><option value="FAMILY_ENTRY"></option></select></div><input className="field" style={{ marginTop: 8 }} name="instructions" placeholder="饭前/饭后及其他注意事项" /><input className="field" style={{ marginTop: 8 }} name="stockCount" type="number" min="0" placeholder="当前库存(可选)" /><button className="btn primary block" style={{ marginTop: 8 }}></button><Notice text={notice} /></form>;
}
export function CheckInButton({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter(); const [notice, setNotice] = useState("");
async function create() { try { await api("/api/check-ins", "POST", { deviceUuid, prompt: "家里人想知道您今天是否安好。" }); setNotice("报平安请求已送到长辈终端。"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "发送失败。"); } }
return <div><button className="btn primary block" onClick={() => void create()}></button><Notice text={notice} /></div>;
}
export function AlertActions({ alertId, status }: { alertId: string; status: string }) {
const router = useRouter(); const [busy, setBusy] = useState(false);
async function act(action: string) { setBusy(true); try { let note; if (action !== "claim") { note = window.prompt(action === "resolve" ? "请填写处理结果" : "请填写忽略原因") || ""; if (!note && action === "resolve") return; } await api("/api/alerts", "PATCH", { alertId, action, note }); router.refresh(); } finally { setBusy(false); } }
if (status === "RESOLVED" || status === "DISMISSED") return <span className="tag"></span>;
return <div className="button-row"><button className="btn small" disabled={busy} onClick={() => void act("claim")}></button><button className="btn primary small" disabled={busy} onClick={() => void act("resolve")}></button><button className="btn small" disabled={busy} onClick={() => void act("dismiss")}></button></div>;
}
export function InviteMemberForm({ deviceUuid }: { deviceUuid: string }) {
const [url, setUrl] = useState(""); const [notice, setNotice] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = new FormData(event.currentTarget); try { const result = await api("/api/family-circle/invite", "POST", { deviceUuid, relationLabel: form.get("relationLabel"), role: form.get("role") }); setUrl(result.inviteUrl); setNotice("邀请链接 24 小时内有效。"); } catch (e) { setNotice(e instanceof Error ? e.message : "邀请失败。"); } }
return <form className="card card-pad" onSubmit={submit}><div className="button-row"><input className="field" name="relationLabel" placeholder="关系,如:儿子" /><select className="field" name="role"><option value="CAREGIVER"></option><option value="CARING">怀</option></select><button className="btn primary"></button></div>{url ? <div className="button-row" style={{ marginTop: 8 }}><input className="field" readOnly value={url} /><button type="button" className="btn" onClick={() => void navigator.clipboard.writeText(url)}></button></div> : null}<Notice text={notice} /></form>;
}
export function JoinFamilyButton({ token }: { token: string }) {
const router = useRouter(); const [notice, setNotice] = useState("");
async function join() { try { await api("/api/family-circle/join", "POST", { token }); setNotice("已加入家庭守护圈,正在打开家庭页……"); router.push("/family"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "加入失败。"); } }
return <div><button className="btn primary block" onClick={() => void join()}></button><Notice text={notice} /></div>;
}

View File

@ -1,16 +1,16 @@
import Link from "next/link";
import { ChevronLeft, Home, MessageCircle, Smartphone, UserRound } from "lucide-react";
import { ChevronLeft, HeartHandshake, House, ShieldCheck, Stethoscope, UserRound } from "lucide-react";
import type { ReactNode } from "react";
const navigationItems = [
{ href: "/", label: "概览", icon: Home },
{ href: "/messages", label: "留言", icon: MessageCircle },
{ href: "/devices", label: "设备", icon: Smartphone },
{ href: "/guard", label: "守护", icon: ShieldCheck },
{ href: "/family", label: "家庭", icon: House },
{ href: "/care", label: "照护", icon: Stethoscope },
{ href: "/companion", label: "陪伴", icon: HeartHandshake },
{ href: "/settings", label: "我的", icon: UserRound },
];
function isActivePath(currentPath: string, href: string) {
if (href === "/") return currentPath === "/";
return currentPath === href || currentPath.startsWith(`${href}/`);
}
@ -76,7 +76,7 @@ export function PanelShell({
<Link key={item.href} href={item.href} className={`nav-item${active ? " active" : ""}`}>
<Icon size={22} strokeWidth={1.8} />
<span>{item.label}</span>
{item.href === "/messages" && unreadCount > 0 ? <span className="nav-badge">{unreadCount}</span> : null}
{item.href === "/family" && unreadCount > 0 ? <span className="nav-badge">{unreadCount}</span> : null}
</Link>
);
})}

29
lib/device-auth.ts Normal file
View File

@ -0,0 +1,29 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/prisma";
export function hashDeviceToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function createDeviceToken() {
return randomBytes(32).toString("base64url");
}
export function readDeviceToken(request: Request) {
const authorization = request.headers.get("authorization") || "";
return authorization.startsWith("Bearer ")
? authorization.slice(7).trim()
: request.headers.get("x-device-token")?.trim() || "";
}
export async function requireDeviceAuth(request: Request, deviceUuid: string) {
const token = readDeviceToken(request);
if (!token) throw new Error("设备认证信息缺失,请重新注册设备。");
const device = await prisma.elderDevice.findUnique({ where: { deviceUuid }, select: { id: true, deviceUuid: true, deviceTokenHash: true } });
if (!device?.deviceTokenHash) throw new Error("设备尚未完成安全注册。");
const actual = Buffer.from(hashDeviceToken(token));
const expected = Buffer.from(device.deviceTokenHash);
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new Error("设备认证失败,请重新注册设备。");
return device;
}

372
lib/family-guard.ts Normal file
View File

@ -0,0 +1,372 @@
import {
AlertLevel,
AlertStatus,
CareTaskStatus,
CheckInStatus,
ContentVisibility,
FamilyRole,
MedicationDoseStatus,
MedicationSource,
MembershipStatus,
MemoPriority,
InvitationStatus,
} from "@prisma/client";
import { createHash, randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
const ACTIVE_MEMBER = { status: MembershipStatus.ACTIVE } as const;
function requiredText(value: unknown, label: string, max = 500) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${label}不能为空。`);
return value.trim().slice(0, max);
}
function optionalText(value: unknown, max = 500) {
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null;
}
function parseDate(value: unknown) {
if (typeof value !== "string" || !value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export async function ensureFamilyCircle(caregiverId: string, deviceUuid?: string) {
const binding = await prisma.deviceBinding.findFirst({
where: {
caregiverId,
elderDevice: deviceUuid ? { deviceUuid } : undefined,
},
orderBy: { createdAt: "asc" },
include: {
elderDevice: {
include: { elderProfile: { include: { familyCircle: true } } },
},
},
});
if (!binding) throw new Error("请先绑定一台长辈设备。 ");
let circle = binding.elderDevice.elderProfile?.familyCircle;
if (!circle) {
circle = await prisma.$transaction(async (tx) => {
const profile = await tx.elderProfile.upsert({
where: { elderDeviceId: binding.elderDeviceId },
update: {},
create: {
elderDeviceId: binding.elderDeviceId,
preferredName: binding.alias || binding.elderDevice.displayName || "长辈",
consentSetting: { create: {} },
},
});
return tx.familyCircle.upsert({
where: { elderProfileId: profile.id },
update: {},
create: {
elderProfileId: profile.id,
createdById: caregiverId,
name: `${profile.preferredName}的守护圈`,
},
});
});
}
const allBindings = await prisma.deviceBinding.findMany({
where: { elderDeviceId: binding.elderDeviceId },
orderBy: { createdAt: "asc" },
select: { caregiverId: true, createdAt: true },
});
await prisma.$transaction(
allBindings.map((item, index) =>
prisma.familyMembership.upsert({
where: {
familyCircleId_caregiverId: {
familyCircleId: circle!.id,
caregiverId: item.caregiverId,
},
},
update: { status: MembershipStatus.ACTIVE },
create: {
familyCircleId: circle!.id,
caregiverId: item.caregiverId,
role: index === 0 ? FamilyRole.ADMIN : FamilyRole.CAREGIVER,
dutyOrder: index + 1,
},
}),
),
);
return circle;
}
export async function requireCircleMember(caregiverId: string, circleId: string) {
const member = await prisma.familyMembership.findFirst({
where: { caregiverId, familyCircleId: circleId, ...ACTIVE_MEMBER },
});
if (!member) throw new Error("您不是这个家庭守护圈的成员。");
return member;
}
export async function getFamilyWorkspace(caregiverId: string, deviceUuid?: string) {
const circle = await ensureFamilyCircle(caregiverId, deviceUuid);
await requireCircleMember(caregiverId, circle.id);
return prisma.familyCircle.findUniqueOrThrow({
where: { id: circle.id },
include: {
elderProfile: { include: { elderDevice: true, consentSetting: true } },
memberships: {
where: ACTIVE_MEMBER,
orderBy: [{ dutyOrder: "asc" }, { createdAt: "asc" }],
include: { caregiver: { select: { id: true, nickname: true, username: true } } },
},
posts: { orderBy: [{ pinned: "desc" }, { createdAt: "desc" }], take: 30, include: { author: true } },
memos: { orderBy: [{ priority: "desc" }, { updatedAt: "desc" }], take: 30, include: { author: true } },
tasks: { orderBy: [{ status: "asc" }, { dueAt: "asc" }, { createdAt: "desc" }], take: 40, include: { createdBy: true, assignedTo: true } },
medicationPlans: { orderBy: { createdAt: "desc" }, include: { doses: { orderBy: { scheduledFor: "desc" }, take: 14 } } },
checkIns: { orderBy: { createdAt: "desc" }, take: 20, include: { requestedBy: true } },
alerts: { orderBy: { createdAt: "desc" }, take: 30, include: { claimedBy: true, safetySignal: true } },
},
});
}
function invitationHash(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export async function createFamilyInvitation(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role !== FamilyRole.ADMIN) throw new Error("只有管理员可以邀请家庭成员。");
const token = randomBytes(24).toString("base64url");
const role = input.role === FamilyRole.CAREGIVER ? FamilyRole.CAREGIVER : FamilyRole.CARING;
await prisma.familyInvitation.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
tokenHash: invitationHash(token), role,
relationLabel: optionalText(input.relationLabel, 30),
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
},
});
await audit(circle.id, caregiverId, "INVITE", "FamilyMembership", undefined, { role });
const baseUrl = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/$/, "");
return { inviteUrl: `${baseUrl}/family/join?token=${encodeURIComponent(token)}`, expiresAt: new Date(Date.now() + 24 * 60 * 60_000) };
}
export async function acceptFamilyInvitation(caregiverId: string, token: string) {
const invitation = await prisma.familyInvitation.findUnique({
where: { tokenHash: invitationHash(requiredText(token, "邀请口令", 200)) },
include: { familyCircle: { include: { elderProfile: true } } },
});
if (!invitation || invitation.status !== InvitationStatus.PENDING) throw new Error("邀请链接无效或已被使用。");
if (invitation.expiresAt <= new Date()) {
await prisma.familyInvitation.update({ where: { id: invitation.id }, data: { status: InvitationStatus.EXPIRED } });
throw new Error("邀请链接已过期,请让管理员重新邀请。");
}
await prisma.$transaction([
prisma.familyMembership.upsert({
where: { familyCircleId_caregiverId: { familyCircleId: invitation.familyCircleId, caregiverId } },
update: { status: MembershipStatus.ACTIVE, role: invitation.role, relationLabel: invitation.relationLabel },
create: { familyCircleId: invitation.familyCircleId, caregiverId, role: invitation.role, relationLabel: invitation.relationLabel },
}),
prisma.deviceBinding.upsert({
where: { caregiverId_elderDeviceId: { caregiverId, elderDeviceId: invitation.familyCircle.elderProfile.elderDeviceId } },
update: {}, create: { caregiverId, elderDeviceId: invitation.familyCircle.elderProfile.elderDeviceId },
}),
prisma.familyInvitation.update({ where: { id: invitation.id }, data: { status: InvitationStatus.ACCEPTED, acceptedAt: new Date() } }),
]);
await audit(invitation.familyCircleId, caregiverId, "JOIN", "FamilyMembership");
return invitation.familyCircle;
}
export async function createFamilyPost(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
await requireCircleMember(caregiverId, circle.id);
const visibility = Object.values(ContentVisibility).includes(input.visibility as ContentVisibility)
? (input.visibility as ContentVisibility) : ContentVisibility.FAMILY_AND_ELDER;
const post = await prisma.familyPost.create({
data: { familyCircleId: circle.id, authorId: caregiverId, content: requiredText(input.content, "动态内容", 1000), visibility },
});
await audit(circle.id, caregiverId, "CREATE", "FamilyPost", post.id);
return post;
}
export async function createFamilyMemo(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能修改家庭备忘录。");
const priority = Object.values(MemoPriority).includes(input.priority as MemoPriority)
? (input.priority as MemoPriority) : MemoPriority.NORMAL;
const memo = await prisma.familyMemo.create({
data: {
familyCircleId: circle.id,
authorId: caregiverId,
title: requiredText(input.title, "备忘标题", 80),
content: requiredText(input.content, "备忘内容", 1000),
priority,
visibility: input.visibility === ContentVisibility.FAMILY_ONLY ? ContentVisibility.FAMILY_ONLY : ContentVisibility.FAMILY_AND_ELDER,
},
});
await audit(circle.id, caregiverId, "CREATE", "FamilyMemo", memo.id);
return memo;
}
export async function createCareTask(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能创建照护任务。");
const task = await prisma.careTask.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
title: requiredText(input.title, "任务标题", 100),
description: optionalText(input.description, 1000),
dueAt: parseDate(input.dueAt),
},
});
await audit(circle.id, caregiverId, "CREATE", "CareTask", task.id);
return task;
}
export async function updateCareTask(caregiverId: string, taskId: string, action: string, note?: unknown) {
const task = await prisma.careTask.findUniqueOrThrow({ where: { id: taskId } });
await requireCircleMember(caregiverId, task.familyCircleId);
const data = action === "claim"
? { status: CareTaskStatus.CLAIMED, assignedToId: caregiverId }
: action === "complete"
? { status: CareTaskStatus.COMPLETED, assignedToId: task.assignedToId || caregiverId, completedAt: new Date(), completionNote: optionalText(note, 500) }
: action === "reopen"
? { status: CareTaskStatus.OPEN, assignedToId: null, completedAt: null, completionNote: null }
: null;
if (!data) throw new Error("不支持的任务操作。");
const updated = await prisma.careTask.update({ where: { id: taskId }, data });
await audit(task.familyCircleId, caregiverId, action.toUpperCase(), "CareTask", taskId);
return updated;
}
function parseTimes(value: unknown) {
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
const times = values.map(String).map((v) => v.trim()).filter((v) => /^([01]\d|2[0-3]):[0-5]\d$/.test(v));
if (!times.length) throw new Error("请至少填写一个 HH:mm 格式的服药时间。");
return [...new Set(times)].sort();
}
function scheduledDate(dayOffset: number, time: string) {
const now = new Date();
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset);
const [hour, minute] = time.split(":").map(Number);
date.setHours(hour, minute, 0, 0);
return date;
}
export async function createMedicationPlan(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能管理用药计划。");
const times = parseTimes(input.times);
const source = Object.values(MedicationSource).includes(input.source as MedicationSource)
? (input.source as MedicationSource) : MedicationSource.FAMILY_ENTRY;
const startDate = parseDate(input.startDate) || new Date();
const plan = await prisma.medicationPlan.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
name: requiredText(input.name, "药品名称", 100),
dosage: requiredText(input.dosage, "单次用量", 100),
instructions: optionalText(input.instructions, 300),
timesJson: JSON.stringify(times), source, startDate,
endDate: parseDate(input.endDate),
stockCount: typeof input.stockCount === "number" ? Math.max(0, Math.floor(input.stockCount)) : null,
doses: { create: times.flatMap((time) => Array.from({ length: 7 }, (_, day) => ({ scheduledFor: scheduledDate(day, time) }))) },
},
include: { doses: true },
});
await audit(circle.id, caregiverId, "CREATE", "MedicationPlan", plan.id, { source });
return plan;
}
export async function createCheckIn(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
await requireCircleMember(caregiverId, circle.id);
const checkIn = await prisma.checkInRequest.create({
data: { familyCircleId: circle.id, requestedById: caregiverId, prompt: optionalText(input.prompt, 240), dueAt: parseDate(input.dueAt) },
});
await audit(circle.id, caregiverId, "CREATE", "CheckInRequest", checkIn.id);
return checkIn;
}
export async function updateAlert(caregiverId: string, alertId: string, action: string, note?: unknown) {
const alert = await prisma.safetyAlert.findUniqueOrThrow({ where: { id: alertId } });
await requireCircleMember(caregiverId, alert.familyCircleId);
const data = action === "claim"
? { status: AlertStatus.ACKNOWLEDGED, claimedById: caregiverId, claimedAt: new Date() }
: action === "resolve"
? { status: AlertStatus.RESOLVED, claimedById: alert.claimedById || caregiverId, claimedAt: alert.claimedAt || new Date(), resolvedAt: new Date(), resolutionNote: requiredText(note, "处理结果", 500) }
: action === "dismiss"
? { status: AlertStatus.DISMISSED, claimedById: alert.claimedById || caregiverId, resolvedAt: new Date(), resolutionNote: optionalText(note, 500) || "已确认是误报" }
: null;
if (!data) throw new Error("不支持的告警操作。");
const updated = await prisma.safetyAlert.update({ where: { id: alertId }, data });
await audit(alert.familyCircleId, caregiverId, action.toUpperCase(), "SafetyAlert", alertId, { note });
return updated;
}
export async function getDeviceAgenda(deviceUuid: string) {
const profile = await prisma.elderProfile.findFirst({
where: { elderDevice: { deviceUuid } },
include: { familyCircle: true },
});
if (!profile?.familyCircle) return { circle: null, posts: [], memos: [], doses: [], checkIns: [] };
const circleId = profile.familyCircle.id;
const now = new Date();
const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1);
const [posts, memos, doses, checkIns] = await Promise.all([
prisma.familyPost.findMany({ where: { familyCircleId: circleId, visibility: { in: [ContentVisibility.ELDER_ONLY, ContentVisibility.FAMILY_AND_ELDER] }, deliveredAt: null }, include: { author: true }, orderBy: { createdAt: "asc" }, take: 8 }),
prisma.familyMemo.findMany({ where: { familyCircleId: circleId, visibility: { in: [ContentVisibility.ELDER_ONLY, ContentVisibility.FAMILY_AND_ELDER] } }, orderBy: [{ priority: "desc" }, { updatedAt: "desc" }], take: 12 }),
prisma.medicationDose.findMany({ where: { medicationPlan: { familyCircleId: circleId, active: true }, scheduledFor: { gte: dayStart, lt: dayEnd }, status: { in: [MedicationDoseStatus.SCHEDULED, MedicationDoseStatus.REMINDING, MedicationDoseStatus.SNOOZED] } }, include: { medicationPlan: true }, orderBy: { scheduledFor: "asc" } }),
prisma.checkInRequest.findMany({ where: { familyCircleId: circleId, status: CheckInStatus.PENDING }, orderBy: { createdAt: "asc" }, take: 5 }),
]);
if (posts.length) {
await prisma.familyPost.updateMany({ where: { id: { in: posts.map((post) => post.id) } }, data: { deliveredAt: new Date() } });
}
return { circle: profile.familyCircle, posts, memos, doses, checkIns };
}
export async function updateMedicationDose(deviceUuid: string, doseId: string, status: MedicationDoseStatus, note?: unknown, snoozeMinutes = 10) {
const dose = await prisma.medicationDose.findFirst({ where: { id: doseId, medicationPlan: { familyCircle: { elderProfile: { elderDevice: { deviceUuid } } } } }, include: { medicationPlan: true } });
if (!dose) throw new Error("没有找到这次服药提醒。");
if (dose.status === MedicationDoseStatus.TAKEN || dose.status === MedicationDoseStatus.SKIPPED) throw new Error("这次服药已经确认,不能重复提交。");
return prisma.medicationDose.update({
where: { id: doseId },
data: {
status,
respondedAt: status === MedicationDoseStatus.SNOOZED ? null : new Date(),
snoozedUntil: status === MedicationDoseStatus.SNOOZED ? new Date(Date.now() + Math.min(Math.max(snoozeMinutes, 1), 60) * 60_000) : null,
note: optionalText(note, 500),
},
});
}
export async function respondCheckIn(deviceUuid: string, checkInId: string, status: CheckInStatus, responseText?: unknown) {
const checkIn = await prisma.checkInRequest.findFirst({ where: { id: checkInId, familyCircle: { elderProfile: { elderDevice: { deviceUuid } } }, status: CheckInStatus.PENDING } });
if (!checkIn) throw new Error("没有找到待回应的报平安请求。");
return prisma.checkInRequest.update({ where: { id: checkInId }, data: { status, responseText: optionalText(responseText, 500), respondedAt: new Date() } });
}
export async function createSafetyAlertForDevice(deviceUuid: string, input: Record<string, unknown>) {
const profile = await prisma.elderProfile.findFirst({ where: { elderDevice: { deviceUuid } }, include: { familyCircle: true } });
if (!profile?.familyCircle) throw new Error("这台设备还没有加入家庭守护圈。");
const category = requiredText(input.category, "风险类别", 80);
const summary = requiredText(input.summary, "风险说明", 500);
const since = new Date(Date.now() - 30 * 60_000);
const duplicate = await prisma.safetyAlert.findFirst({ where: { familyCircleId: profile.familyCircle.id, status: { in: [AlertStatus.OPEN, AlertStatus.ACKNOWLEDGED] }, safetySignal: { category, createdAt: { gte: since } } }, orderBy: { createdAt: "desc" } });
if (duplicate) return { alert: duplicate, deduplicated: true };
const level = Object.values(AlertLevel).includes(input.level as AlertLevel) ? (input.level as AlertLevel) : AlertLevel.ORANGE;
const signal = await prisma.safetySignal.create({ data: { familyCircleId: profile.familyCircle.id, category, summary, contextText: optionalText(input.contextText, 1000), confidence: typeof input.confidence === "number" ? Math.min(Math.max(input.confidence, 0), 1) : null } });
const alert = await prisma.safetyAlert.create({ data: { familyCircleId: profile.familyCircle.id, safetySignalId: signal.id, level, title: requiredText(input.title, "提醒标题", 100), summary } });
return { alert, deduplicated: false };
}
async function audit(circleId: string, actorId: string | null, action: string, entityType: string, entityId?: string, detail?: unknown) {
await prisma.auditLog.create({ data: { familyCircleId: circleId, actorId, action, entityType, entityId, detailJson: detail === undefined ? null : JSON.stringify(detail) } });
}

11
lib/family-page.ts Normal file
View File

@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { getFamilyWorkspace } from "@/lib/family-guard";
export async function requireFamilyWorkspace(caregiverId: string) {
try {
return await getFamilyWorkspace(caregiverId);
} catch (error) {
if (error instanceof Error && error.message.includes("绑定一台长辈设备")) redirect("/devices");
throw error;
}
}

View File

@ -585,6 +585,21 @@ export async function recordToolCall(input: ToolCallInput) {
});
createdMessagePublicId = createdMessage.publicId;
const circle = await tx.familyCircle.findFirst({
where: { elderProfile: { elderDeviceId: device.id } },
select: { id: true },
});
if (circle) {
await tx.familyPost.create({
data: {
familyCircleId: circle.id,
content: messageText,
elderAuthored: true,
visibility: "FAMILY_AND_ELDER",
deliveredAt: new Date(),
},
});
}
}
return {
@ -694,4 +709,4 @@ export async function getCaregiverDashboard(caregiverId: string) {
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
})),
};
}
}

View File

@ -426,4 +426,33 @@ export async function sendCaregiverTestPush(input: {
topic: `test-${input.caregiverId.slice(0, 20)}`,
collectFailureDetails: true,
});
}
}
export async function sendGuardAlertPush(alertId: string) {
if (!hasPushConfiguration()) return;
const alert = await prisma.safetyAlert.findUnique({
where: { id: alertId },
include: {
familyCircle: {
include: {
elderProfile: { include: { elderDevice: true } },
memberships: {
where: { status: "ACTIVE", emergencyPush: true },
include: { caregiver: { include: { pushSubscriptions: true } } },
},
},
},
},
});
if (!alert) return;
const subscriptions = alert.familyCircle.memberships.flatMap((member) => member.caregiver.pushSubscriptions);
const payload = JSON.stringify({
title: `${alert.level === "RED" ? "紧急关注" : "守护提醒"} · ${alert.familyCircle.elderProfile.preferredName}`,
body: truncateText(alert.summary, 72),
url: absoluteUrl(`/guard#alert-${alert.id}`),
icon: absoluteUrl("/pwa/icon-192x192.png"),
badge: absoluteUrl("/pwa/badge-96x96.png"),
tag: `guard-alert-${alert.id}`,
});
await deliverPushToSubscriptions({ subscriptions, payload, topic: `guard-${alert.id}` });
}

View File

@ -0,0 +1,398 @@
-- CreateEnum
CREATE TYPE "FamilyRole" AS ENUM ('ADMIN', 'CAREGIVER', 'CARING');
-- CreateEnum
CREATE TYPE "MembershipStatus" AS ENUM ('ACTIVE', 'REMOVED');
-- CreateEnum
CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED');
-- CreateEnum
CREATE TYPE "ContentVisibility" AS ENUM ('ELDER_ONLY', 'FAMILY_ONLY', 'FAMILY_AND_ELDER');
-- CreateEnum
CREATE TYPE "MemoPriority" AS ENUM ('NORMAL', 'IMPORTANT', 'PINNED');
-- CreateEnum
CREATE TYPE "CareTaskStatus" AS ENUM ('OPEN', 'CLAIMED', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "MedicationSource" AS ENUM ('PRESCRIPTION', 'PACKAGE_LABEL', 'FAMILY_ENTRY');
-- CreateEnum
CREATE TYPE "MedicationDoseStatus" AS ENUM ('SCHEDULED', 'REMINDING', 'TAKEN', 'SNOOZED', 'SKIPPED', 'MISSED', 'NEEDS_FAMILY_CONFIRMATION');
-- CreateEnum
CREATE TYPE "CheckInStatus" AS ENUM ('PENDING', 'SAFE', 'NEEDS_CONTACT', 'DECLINED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "AlertLevel" AS ENUM ('RED', 'ORANGE', 'YELLOW', 'BLUE');
-- CreateEnum
CREATE TYPE "AlertStatus" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'DISMISSED');
-- AlterTable
ALTER TABLE "ElderDevice" ADD COLUMN "deviceTokenHash" TEXT;
-- CreateTable
CREATE TABLE "ElderProfile" (
"id" TEXT NOT NULL,
"elderDeviceId" TEXT NOT NULL,
"preferredName" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'Asia/Shanghai',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ElderProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyCircle" (
"id" TEXT NOT NULL,
"elderProfileId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FamilyCircle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyInvitation" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"role" "FamilyRole" NOT NULL DEFAULT 'CARING',
"relationLabel" TEXT,
"status" "InvitationStatus" NOT NULL DEFAULT 'PENDING',
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FamilyInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyMembership" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"caregiverId" TEXT NOT NULL,
"role" "FamilyRole" NOT NULL DEFAULT 'CARING',
"relationLabel" TEXT,
"status" "MembershipStatus" NOT NULL DEFAULT 'ACTIVE',
"emergencyPush" BOOLEAN NOT NULL DEFAULT true,
"routinePush" BOOLEAN NOT NULL DEFAULT true,
"dutyOrder" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FamilyMembership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyPost" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"authorId" TEXT,
"content" TEXT NOT NULL,
"visibility" "ContentVisibility" NOT NULL DEFAULT 'FAMILY_AND_ELDER',
"elderAuthored" BOOLEAN NOT NULL DEFAULT false,
"pinned" BOOLEAN NOT NULL DEFAULT false,
"deliveredAt" TIMESTAMP(3),
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FamilyPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyMemo" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"priority" "MemoPriority" NOT NULL DEFAULT 'NORMAL',
"visibility" "ContentVisibility" NOT NULL DEFAULT 'FAMILY_AND_ELDER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FamilyMemo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CareTask" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"assignedToId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"dueAt" TIMESTAMP(3),
"status" "CareTaskStatus" NOT NULL DEFAULT 'OPEN',
"completedAt" TIMESTAMP(3),
"completionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CareTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MedicationPlan" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"name" TEXT NOT NULL,
"dosage" TEXT NOT NULL,
"instructions" TEXT,
"timesJson" TEXT NOT NULL,
"source" "MedicationSource" NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"stockCount" INTEGER,
"active" BOOLEAN NOT NULL DEFAULT true,
"pausedReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MedicationPlan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MedicationDose" (
"id" TEXT NOT NULL,
"medicationPlanId" TEXT NOT NULL,
"scheduledFor" TIMESTAMP(3) NOT NULL,
"status" "MedicationDoseStatus" NOT NULL DEFAULT 'SCHEDULED',
"respondedAt" TIMESTAMP(3),
"snoozedUntil" TIMESTAMP(3),
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MedicationDose_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CheckInRequest" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"prompt" TEXT,
"dueAt" TIMESTAMP(3),
"status" "CheckInStatus" NOT NULL DEFAULT 'PENDING',
"responseText" TEXT,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CheckInRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SafetySignal" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"category" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"confidence" DOUBLE PRECISION,
"contextText" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SafetySignal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SafetyAlert" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"safetySignalId" TEXT NOT NULL,
"claimedById" TEXT,
"level" "AlertLevel" NOT NULL,
"status" "AlertStatus" NOT NULL DEFAULT 'OPEN',
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"resolutionNote" TEXT,
"claimedAt" TIMESTAMP(3),
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SafetyAlert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyMemory" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"story" TEXT NOT NULL,
"mediaUrl" TEXT,
"occurredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FamilyMemory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConsentSetting" (
"id" TEXT NOT NULL,
"elderProfileId" TEXT NOT NULL,
"shareTranscripts" BOOLEAN NOT NULL DEFAULT true,
"shareRawAudio" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConsentSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"actorId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
"detailJson" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ElderProfile_elderDeviceId_key" ON "ElderProfile"("elderDeviceId");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyCircle_elderProfileId_key" ON "FamilyCircle"("elderProfileId");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyInvitation_tokenHash_key" ON "FamilyInvitation"("tokenHash");
-- CreateIndex
CREATE INDEX "FamilyInvitation_familyCircleId_status_expiresAt_idx" ON "FamilyInvitation"("familyCircleId", "status", "expiresAt");
-- CreateIndex
CREATE INDEX "FamilyMembership_caregiverId_status_idx" ON "FamilyMembership"("caregiverId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyMembership_familyCircleId_caregiverId_key" ON "FamilyMembership"("familyCircleId", "caregiverId");
-- CreateIndex
CREATE INDEX "FamilyPost_familyCircleId_createdAt_idx" ON "FamilyPost"("familyCircleId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "FamilyMemo_familyCircleId_priority_updatedAt_idx" ON "FamilyMemo"("familyCircleId", "priority", "updatedAt" DESC);
-- CreateIndex
CREATE INDEX "CareTask_familyCircleId_status_dueAt_idx" ON "CareTask"("familyCircleId", "status", "dueAt");
-- CreateIndex
CREATE INDEX "MedicationPlan_familyCircleId_active_idx" ON "MedicationPlan"("familyCircleId", "active");
-- CreateIndex
CREATE INDEX "MedicationDose_scheduledFor_status_idx" ON "MedicationDose"("scheduledFor", "status");
-- CreateIndex
CREATE UNIQUE INDEX "MedicationDose_medicationPlanId_scheduledFor_key" ON "MedicationDose"("medicationPlanId", "scheduledFor");
-- CreateIndex
CREATE INDEX "CheckInRequest_familyCircleId_status_createdAt_idx" ON "CheckInRequest"("familyCircleId", "status", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "SafetySignal_familyCircleId_category_createdAt_idx" ON "SafetySignal"("familyCircleId", "category", "createdAt" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "SafetyAlert_safetySignalId_key" ON "SafetyAlert"("safetySignalId");
-- CreateIndex
CREATE INDEX "SafetyAlert_familyCircleId_status_createdAt_idx" ON "SafetyAlert"("familyCircleId", "status", "createdAt" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "ConsentSetting_elderProfileId_key" ON "ConsentSetting"("elderProfileId");
-- CreateIndex
CREATE INDEX "AuditLog_familyCircleId_createdAt_idx" ON "AuditLog"("familyCircleId", "createdAt" DESC);
-- AddForeignKey
ALTER TABLE "ElderProfile" ADD CONSTRAINT "ElderProfile_elderDeviceId_fkey" FOREIGN KEY ("elderDeviceId") REFERENCES "ElderDevice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyCircle" ADD CONSTRAINT "FamilyCircle_elderProfileId_fkey" FOREIGN KEY ("elderProfileId") REFERENCES "ElderProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyCircle" ADD CONSTRAINT "FamilyCircle_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyInvitation" ADD CONSTRAINT "FamilyInvitation_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyInvitation" ADD CONSTRAINT "FamilyInvitation_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMembership" ADD CONSTRAINT "FamilyMembership_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMembership" ADD CONSTRAINT "FamilyMembership_caregiverId_fkey" FOREIGN KEY ("caregiverId") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyPost" ADD CONSTRAINT "FamilyPost_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyPost" ADD CONSTRAINT "FamilyPost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "CaregiverAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMemo" ADD CONSTRAINT "FamilyMemo_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMemo" ADD CONSTRAINT "FamilyMemo_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CareTask" ADD CONSTRAINT "CareTask_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CareTask" ADD CONSTRAINT "CareTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CareTask" ADD CONSTRAINT "CareTask_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "CaregiverAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MedicationPlan" ADD CONSTRAINT "MedicationPlan_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MedicationPlan" ADD CONSTRAINT "MedicationPlan_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MedicationDose" ADD CONSTRAINT "MedicationDose_medicationPlanId_fkey" FOREIGN KEY ("medicationPlanId") REFERENCES "MedicationPlan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckInRequest" ADD CONSTRAINT "CheckInRequest_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckInRequest" ADD CONSTRAINT "CheckInRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SafetySignal" ADD CONSTRAINT "SafetySignal_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SafetyAlert" ADD CONSTRAINT "SafetyAlert_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SafetyAlert" ADD CONSTRAINT "SafetyAlert_safetySignalId_fkey" FOREIGN KEY ("safetySignalId") REFERENCES "SafetySignal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SafetyAlert" ADD CONSTRAINT "SafetyAlert_claimedById_fkey" FOREIGN KEY ("claimedById") REFERENCES "CaregiverAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMemory" ADD CONSTRAINT "FamilyMemory_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConsentSetting" ADD CONSTRAINT "ConsentSetting_elderProfileId_fkey" FOREIGN KEY ("elderProfileId") REFERENCES "ElderProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "CaregiverAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -45,17 +45,103 @@ enum ToolCallStatus {
FAILURE
}
enum FamilyRole {
ADMIN
CAREGIVER
CARING
}
enum MembershipStatus {
ACTIVE
REMOVED
}
enum InvitationStatus {
PENDING
ACCEPTED
EXPIRED
REVOKED
}
enum ContentVisibility {
ELDER_ONLY
FAMILY_ONLY
FAMILY_AND_ELDER
}
enum MemoPriority {
NORMAL
IMPORTANT
PINNED
}
enum CareTaskStatus {
OPEN
CLAIMED
COMPLETED
CANCELLED
}
enum MedicationSource {
PRESCRIPTION
PACKAGE_LABEL
FAMILY_ENTRY
}
enum MedicationDoseStatus {
SCHEDULED
REMINDING
TAKEN
SNOOZED
SKIPPED
MISSED
NEEDS_FAMILY_CONFIRMATION
}
enum CheckInStatus {
PENDING
SAFE
NEEDS_CONTACT
DECLINED
EXPIRED
}
enum AlertLevel {
RED
ORANGE
YELLOW
BLUE
}
enum AlertStatus {
OPEN
ACKNOWLEDGED
RESOLVED
DISMISSED
}
model CaregiverAccount {
id String @id @default(cuid())
username String? @unique
passwordHash String?
nickname String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions CaregiverSession[]
bindings DeviceBinding[]
messages FamilyMessage[]
pushSubscriptions PushSubscription[]
id String @id @default(cuid())
username String? @unique
passwordHash String?
nickname String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions CaregiverSession[]
bindings DeviceBinding[]
messages FamilyMessage[]
pushSubscriptions PushSubscription[]
createdCircles FamilyCircle[] @relation("CircleCreator")
memberships FamilyMembership[]
posts FamilyPost[]
memos FamilyMemo[]
createdTasks CareTask[] @relation("TaskCreator")
assignedTasks CareTask[] @relation("TaskAssignee")
medicationPlans MedicationPlan[]
requestedCheckIns CheckInRequest[]
claimedAlerts SafetyAlert[] @relation("AlertClaimer")
auditLogs AuditLog[]
createdInvitations FamilyInvitation[] @relation("InvitationCreator")
}
model CaregiverSession {
@ -84,6 +170,257 @@ model ElderDevice {
conversationTurns ConversationTurn[]
usageEvents UsageEvent[]
toolCalls ToolCallLog[]
deviceTokenHash String?
elderProfile ElderProfile?
}
model ElderProfile {
id String @id @default(cuid())
elderDeviceId String @unique
preferredName String
timezone String @default("Asia/Shanghai")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
familyCircle FamilyCircle?
consentSetting ConsentSetting?
}
model FamilyCircle {
id String @id @default(cuid())
elderProfileId String @unique
createdById String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
elderProfile ElderProfile @relation(fields: [elderProfileId], references: [id], onDelete: Cascade)
createdBy CaregiverAccount @relation("CircleCreator", fields: [createdById], references: [id], onDelete: Restrict)
memberships FamilyMembership[]
posts FamilyPost[]
memos FamilyMemo[]
tasks CareTask[]
medicationPlans MedicationPlan[]
checkIns CheckInRequest[]
safetySignals SafetySignal[]
alerts SafetyAlert[]
memories FamilyMemory[]
auditLogs AuditLog[]
invitations FamilyInvitation[]
}
model FamilyInvitation {
id String @id @default(cuid())
familyCircleId String
createdById String
tokenHash String @unique
role FamilyRole @default(CARING)
relationLabel String?
status InvitationStatus @default(PENDING)
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
createdBy CaregiverAccount @relation("InvitationCreator", fields: [createdById], references: [id], onDelete: Cascade)
@@index([familyCircleId, status, expiresAt])
}
model FamilyMembership {
id String @id @default(cuid())
familyCircleId String
caregiverId String
role FamilyRole @default(CARING)
relationLabel String?
status MembershipStatus @default(ACTIVE)
emergencyPush Boolean @default(true)
routinePush Boolean @default(true)
dutyOrder Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
@@unique([familyCircleId, caregiverId])
@@index([caregiverId, status])
}
model FamilyPost {
id String @id @default(cuid())
familyCircleId String
authorId String?
content String
visibility ContentVisibility @default(FAMILY_AND_ELDER)
elderAuthored Boolean @default(false)
pinned Boolean @default(false)
deliveredAt DateTime?
readAt DateTime?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
author CaregiverAccount? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@index([familyCircleId, createdAt(sort: Desc)])
}
model FamilyMemo {
id String @id @default(cuid())
familyCircleId String
authorId String
title String
content String
priority MemoPriority @default(NORMAL)
visibility ContentVisibility @default(FAMILY_AND_ELDER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
author CaregiverAccount @relation(fields: [authorId], references: [id], onDelete: Restrict)
@@index([familyCircleId, priority, updatedAt(sort: Desc)])
}
model CareTask {
id String @id @default(cuid())
familyCircleId String
createdById String
assignedToId String?
title String
description String?
dueAt DateTime?
status CareTaskStatus @default(OPEN)
completedAt DateTime?
completionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
createdBy CaregiverAccount @relation("TaskCreator", fields: [createdById], references: [id], onDelete: Restrict)
assignedTo CaregiverAccount? @relation("TaskAssignee", fields: [assignedToId], references: [id], onDelete: SetNull)
@@index([familyCircleId, status, dueAt])
}
model MedicationPlan {
id String @id @default(cuid())
familyCircleId String
createdById String
name String
dosage String
instructions String?
timesJson String
source MedicationSource
startDate DateTime
endDate DateTime?
stockCount Int?
active Boolean @default(true)
pausedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
createdBy CaregiverAccount @relation(fields: [createdById], references: [id], onDelete: Restrict)
doses MedicationDose[]
@@index([familyCircleId, active])
}
model MedicationDose {
id String @id @default(cuid())
medicationPlanId String
scheduledFor DateTime
status MedicationDoseStatus @default(SCHEDULED)
respondedAt DateTime?
snoozedUntil DateTime?
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
medicationPlan MedicationPlan @relation(fields: [medicationPlanId], references: [id], onDelete: Cascade)
@@unique([medicationPlanId, scheduledFor])
@@index([scheduledFor, status])
}
model CheckInRequest {
id String @id @default(cuid())
familyCircleId String
requestedById String
prompt String?
dueAt DateTime?
status CheckInStatus @default(PENDING)
responseText String?
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
requestedBy CaregiverAccount @relation(fields: [requestedById], references: [id], onDelete: Restrict)
@@index([familyCircleId, status, createdAt(sort: Desc)])
}
model SafetySignal {
id String @id @default(cuid())
familyCircleId String
category String
summary String
confidence Float?
contextText String?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
alert SafetyAlert?
@@index([familyCircleId, category, createdAt(sort: Desc)])
}
model SafetyAlert {
id String @id @default(cuid())
familyCircleId String
safetySignalId String @unique
claimedById String?
level AlertLevel
status AlertStatus @default(OPEN)
title String
summary String
resolutionNote String?
claimedAt DateTime?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
safetySignal SafetySignal @relation(fields: [safetySignalId], references: [id], onDelete: Cascade)
claimedBy CaregiverAccount? @relation("AlertClaimer", fields: [claimedById], references: [id], onDelete: SetNull)
@@index([familyCircleId, status, createdAt(sort: Desc)])
}
model FamilyMemory {
id String @id @default(cuid())
familyCircleId String
title String
story String
mediaUrl String?
occurredAt DateTime?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
}
model ConsentSetting {
id String @id @default(cuid())
elderProfileId String @unique
shareTranscripts Boolean @default(true)
shareRawAudio Boolean @default(false)
updatedAt DateTime @updatedAt
elderProfile ElderProfile @relation(fields: [elderProfileId], references: [id], onDelete: Cascade)
}
model AuditLog {
id String @id @default(cuid())
familyCircleId String
actorId String?
action String
entityType String
entityId String?
detailJson String?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
actor CaregiverAccount? @relation(fields: [actorId], references: [id], onDelete: SetNull)
@@index([familyCircleId, createdAt(sort: Desc)])
}
model DeviceBinding {
@ -175,4 +512,4 @@ model ToolCallLog {
@@index([elderDeviceId, createdAt(sort: Desc)])
@@index([toolName, createdAt(sort: Desc)])
}
}