家庭守护圈:多人加入、角色权限、24 小时邀请链接、旧设备绑定自动兼容。
家庭动态:老人/家属发言、三种可见范围,家庭内部内容不会播给老人。 共同照护:家庭备忘录、任务创建、认领、完成与交接记录。 用药闭环:计划、未来 7 天提醒实例、确认服用、延后、异常反馈。 风险闭环:红橙黄蓝分级、30 分钟去重、推送、认领、处理与误报记录。 报平安:家属发起、老人语音回应、状态同步。 Web 导航升级为守护、家庭、照护、陪伴、我的。 设备接口已升级为设备令牌认证,UUID 不再单独承担认证。
This commit is contained in:
parent
b7579c5947
commit
747dcd359a
13
app/api/alerts/route.ts
Normal file
13
app/api/alerts/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
22
app/api/care-tasks/route.ts
Normal file
22
app/api/care-tasks/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
12
app/api/check-ins/route.ts
Normal file
12
app/api/check-ins/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
18
app/api/device/agenda/route.ts
Normal file
18
app/api/device/agenda/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
15
app/api/device/alerts/route.ts
Normal file
15
app/api/device/alerts/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
1
app/api/device/content/route.ts
Normal file
1
app/api/device/content/route.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { GET } from "@/app/api/device/agenda/route";
|
||||||
@ -2,6 +2,7 @@ import { ConversationRole } from "@prisma/client";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { recordConversationTurns } from "@/lib/monitor-data";
|
import { recordConversationTurns } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
function toConversationRole(rawRole: unknown) {
|
function toConversationRole(rawRole: unknown) {
|
||||||
switch (rawRole) {
|
switch (rawRole) {
|
||||||
@ -55,6 +56,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, body.deviceUuid);
|
||||||
const result = await recordConversationTurns({
|
const result = await recordConversationTurns({
|
||||||
deviceUuid: body.deviceUuid,
|
deviceUuid: body.deviceUuid,
|
||||||
sessionId: typeof body.sessionId === "string" ? body.sessionId : undefined,
|
sessionId: typeof body.sessionId === "string" ? body.sessionId : undefined,
|
||||||
@ -71,4 +73,4 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/api/device/events/route.ts
Normal file
28
app/api/device/events/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { markFamilyMessagesAsRead } from "@/lib/monitor-data";
|
import { markFamilyMessagesAsRead } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@ -24,6 +25,7 @@ export async function POST(request: Request) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, body.deviceUuid);
|
||||||
await markFamilyMessagesAsRead(body.deviceUuid, messageIds);
|
await markFamilyMessagesAsRead(body.deviceUuid, messageIds);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
@ -36,4 +38,4 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { listFamilyMessagesForDevice } from "@/lib/monitor-data";
|
import { listFamilyMessagesForDevice } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const rawDeviceValue =
|
const rawDeviceValue =
|
||||||
@ -20,6 +21,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, rawDeviceValue);
|
||||||
const data = await listFamilyMessagesForDevice(rawDeviceValue, take);
|
const data = await listFamilyMessagesForDevice(rawDeviceValue, take);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -45,4 +47,4 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { createBindUrl, ensureDeviceRegistration } from "@/lib/monitor-data";
|
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) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@ -8,6 +10,7 @@ export async function POST(request: Request) {
|
|||||||
deviceUuid?: string;
|
deviceUuid?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
|
deviceToken?: string;
|
||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
@ -26,10 +29,20 @@ export async function POST(request: Request) {
|
|||||||
appVersion:
|
appVersion:
|
||||||
typeof body.appVersion === "string" ? body.appVersion : undefined,
|
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({
|
return NextResponse.json({
|
||||||
deviceUuid: device.deviceUuid,
|
deviceUuid: device.deviceUuid,
|
||||||
bindUrl: createBindUrl(device.deviceUuid),
|
bindUrl: createBindUrl(device.deviceUuid),
|
||||||
|
deviceToken,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -40,4 +53,4 @@ export async function POST(request: Request) {
|
|||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { ToolCallStatus } from "@prisma/client";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { recordToolCall } from "@/lib/monitor-data";
|
import { recordToolCall } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
function toToolCallStatus(rawStatus: unknown) {
|
function toToolCallStatus(rawStatus: unknown) {
|
||||||
return rawStatus === ToolCallStatus.FAILURE
|
return rawStatus === ToolCallStatus.FAILURE
|
||||||
@ -49,6 +50,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, body.deviceUuid);
|
||||||
const log = await recordToolCall({
|
const log = await recordToolCall({
|
||||||
deviceUuid: body.deviceUuid,
|
deviceUuid: body.deviceUuid,
|
||||||
toolName: body.toolName,
|
toolName: body.toolName,
|
||||||
@ -70,4 +72,4 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { UsageEventType } from "@prisma/client";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { recordUsageEvent } from "@/lib/monitor-data";
|
import { recordUsageEvent } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
function toUsageEventType(rawType: unknown) {
|
function toUsageEventType(rawType: unknown) {
|
||||||
switch (rawType) {
|
switch (rawType) {
|
||||||
@ -46,6 +47,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, body.deviceUuid);
|
||||||
await recordUsageEvent({
|
await recordUsageEvent({
|
||||||
deviceUuid: body.deviceUuid,
|
deviceUuid: body.deviceUuid,
|
||||||
eventType: toUsageEventType(body.eventType),
|
eventType: toUsageEventType(body.eventType),
|
||||||
@ -62,4 +64,4 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/api/family-circle/invite/route.ts
Normal file
12
app/api/family-circle/invite/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
13
app/api/family-circle/join/route.ts
Normal file
13
app/api/family-circle/join/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
13
app/api/family-feed/route.ts
Normal file
13
app/api/family-feed/route.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
12
app/api/medications/route.ts
Normal file
12
app/api/medications/route.ts
Normal 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
12
app/api/memos/route.ts
Normal 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
15
app/care/page.tsx
Normal 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
8
app/companion/page.tsx
Normal 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
8
app/family/join/page.tsx
Normal 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
18
app/family/page.tsx
Normal 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
23
app/guard/page.tsx
Normal 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>;
|
||||||
|
}
|
||||||
88
app/page.tsx
88
app/page.tsx
@ -1,86 +1,2 @@
|
|||||||
import Link from "next/link";
|
import { redirect } from "next/navigation";
|
||||||
import { ChevronRight, PenLine, ScanLine } from "lucide-react";
|
export default function Home() { redirect("/guard"); }
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
73
components/family-guard-actions.tsx
Normal file
73
components/family-guard-actions.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@ -1,16 +1,16 @@
|
|||||||
import Link from "next/link";
|
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";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ href: "/", label: "概览", icon: Home },
|
{ href: "/guard", label: "守护", icon: ShieldCheck },
|
||||||
{ href: "/messages", label: "留言", icon: MessageCircle },
|
{ href: "/family", label: "家庭", icon: House },
|
||||||
{ href: "/devices", label: "设备", icon: Smartphone },
|
{ href: "/care", label: "照护", icon: Stethoscope },
|
||||||
|
{ href: "/companion", label: "陪伴", icon: HeartHandshake },
|
||||||
{ href: "/settings", label: "我的", icon: UserRound },
|
{ href: "/settings", label: "我的", icon: UserRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isActivePath(currentPath: string, href: string) {
|
function isActivePath(currentPath: string, href: string) {
|
||||||
if (href === "/") return currentPath === "/";
|
|
||||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
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" : ""}`}>
|
<Link key={item.href} href={item.href} className={`nav-item${active ? " active" : ""}`}>
|
||||||
<Icon size={22} strokeWidth={1.8} />
|
<Icon size={22} strokeWidth={1.8} />
|
||||||
<span>{item.label}</span>
|
<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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
29
lib/device-auth.ts
Normal file
29
lib/device-auth.ts
Normal 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
372
lib/family-guard.ts
Normal 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
11
lib/family-page.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -585,6 +585,21 @@ export async function recordToolCall(input: ToolCallInput) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createdMessagePublicId = createdMessage.publicId;
|
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 {
|
return {
|
||||||
@ -694,4 +709,4 @@ export async function getCaregiverDashboard(caregiverId: string) {
|
|||||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
31
lib/push.ts
31
lib/push.ts
@ -426,4 +426,33 @@ export async function sendCaregiverTestPush(input: {
|
|||||||
topic: `test-${input.caregiverId.slice(0, 20)}`,
|
topic: `test-${input.caregiverId.slice(0, 20)}`,
|
||||||
collectFailureDetails: true,
|
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}` });
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -45,17 +45,103 @@ enum ToolCallStatus {
|
|||||||
FAILURE
|
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 {
|
model CaregiverAccount {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String? @unique
|
username String? @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
nickname String?
|
nickname String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions CaregiverSession[]
|
sessions CaregiverSession[]
|
||||||
bindings DeviceBinding[]
|
bindings DeviceBinding[]
|
||||||
messages FamilyMessage[]
|
messages FamilyMessage[]
|
||||||
pushSubscriptions PushSubscription[]
|
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 {
|
model CaregiverSession {
|
||||||
@ -84,6 +170,257 @@ model ElderDevice {
|
|||||||
conversationTurns ConversationTurn[]
|
conversationTurns ConversationTurn[]
|
||||||
usageEvents UsageEvent[]
|
usageEvents UsageEvent[]
|
||||||
toolCalls ToolCallLog[]
|
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 {
|
model DeviceBinding {
|
||||||
@ -175,4 +512,4 @@ model ToolCallLog {
|
|||||||
|
|
||||||
@@index([elderDeviceId, createdAt(sort: Desc)])
|
@@index([elderDeviceId, createdAt(sort: Desc)])
|
||||||
@@index([toolName, createdAt(sort: Desc)])
|
@@index([toolName, createdAt(sort: Desc)])
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user