Compare commits

..

2 Commits

Author SHA1 Message Date
153c501cf7 fix: bound AI analysis provider latency 2026-07-09 01:11:29 -07:00
a8776f9d79 feat: complete ops dashboard AI board 2026-07-08 19:47:28 -07:00
16 changed files with 1573 additions and 195 deletions

View File

@@ -223,6 +223,76 @@ async function classifyOrder(orderData: OrderData) {
| Invalid API key | Missing/wrong credentials | Check environment variables | | Invalid API key | Missing/wrong credentials | Check environment variables |
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt | | Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
## Scenario: Elder AI analysis latency controls
### 1. Scope / Trigger
- Trigger: backend elder AI analysis calls an OpenAI-compatible chat model through LangChain and returns a fixed API response to the UI.
- Apply this contract whenever changing `modules/ai/server/config.ts`, `modules/ai/server/analysis.ts`, or any elder AI analysis route that can block on a provider call.
### 2. Signatures
- Runtime config: `getAiRuntimeConfig() -> { success: true, config } | { success: false, reason }`.
- Config fields: `baseUrl`, `apiKey`, `chatModel`, `requestTimeoutMs`, `maxTokens`, `maxRetries`.
- Generation service: `generateElderAiAnalysis(context, elderId) -> ServiceResult<{ analysis }>`.
### 3. Contracts
- Environment keys:
- `AI_API_KEY`: required.
- `AI_BASE_URL`: optional OpenAI-compatible base URL.
- `AI_CHAT_MODEL`: optional model name.
- `AI_REQUEST_TIMEOUT_MS`: optional bounded integer request timeout.
- `AI_MAX_TOKENS`: optional bounded integer generation cap.
- `AI_MAX_RETRIES`: optional bounded integer retry count.
- `ChatOpenAI` construction must pass timeout, token cap, retry count, API key, model, and base URL from the runtime config.
- Provider errors must never persist prompts, resident context, API keys, raw provider messages, or stack traces in `elder_ai_analyses`.
### 4. Validation & Error Matrix
- Missing `AI_API_KEY` -> return `AI_API_KEY 未配置`, persist failed history with `errorCategory: "missing_config"` when organization context exists.
- Provider timeout, `AbortError`, or timeout-like provider message -> return `AI 服务响应超时`, persist failed history with `errorCategory: "timeout"`.
- Other provider failure -> return `AI 服务暂时不可用`, persist failed history with `errorCategory: "provider_error"`.
- Invalid model output -> return `AI 返回结构不符合固定分析格式`, persist failed history with `errorCategory: "schema_validation_failed"`.
### 5. Good/Base/Bad Cases
- Good: configured runtime uses a short timeout, bounded max tokens, and no hidden retry amplification; timeout failures are visible as sanitized failed history.
- Base: provider is slow or unreachable; user receives the timeout reason instead of waiting for default SDK retries and transport timeouts.
- Bad: service relies on LangChain/OpenAI defaults, causing long waits from high token caps, implicit retries, or unbounded request timeouts.
### 6. Tests Required
- Unit: assert `generateElderAiAnalysis` constructs `ChatOpenAI` with runtime-configured `timeout`, `maxTokens`, and `maxRetries`.
- Unit: assert `AbortError` and timeout-like model failures persist `errorCategory: "timeout"`, return `AI 服务响应超时`, and do not persist provider secrets or prompt text.
- Existing failure tests must continue to assert provider errors and schema failures use sanitized persisted history.
### 7. Wrong vs Correct
#### Wrong
```typescript
new ChatOpenAI({
apiKey,
model,
maxTokens: 1800,
});
```
#### Correct
```typescript
new ChatOpenAI({
apiKey: config.apiKey,
model: config.chatModel,
timeout: config.requestTimeoutMs,
maxTokens: config.maxTokens,
maxRetries: config.maxRetries,
configuration: config.baseUrl ? { baseURL: config.baseUrl } : undefined,
});
```
## 6. Prompt Engineering Best Practices ## 6. Prompt Engineering Best Practices
### Use XML Structure for Complex Prompts ### Use XML Structure for Complex Prompts

View File

@@ -0,0 +1,4 @@
{"file":".trellis/spec/backend/type-safety.md","reason":"Check backend AI aggregation and seed helper typing."}
{"file":".trellis/spec/frontend/components.md","reason":"Check dashboard rendering remains Server Component and uses persisted data only."}
{"file":".trellis/spec/shared/code-quality.md","reason":"Check focused tests, no dead code, no console logs, and no forbidden TypeScript patterns."}
{"file":".trellis/spec/guides/pre-implementation-checklist.md","reason":"Check cross-layer dashboard data flow and permission gates."}

View File

@@ -0,0 +1,61 @@
# Design
## Boundaries
This task is a completion pass over existing persisted modules. It does not add schema or a new AI provider path.
- Collaboration data remains owned by `modules/devices`, `modules/notices`, `modules/alerts`, and `modules/family`.
- Dashboard data loading stays in the Server Component at `app/(app)/app/dashboard/page.tsx`.
- Presentation stays in `modules/dashboard/components/DashboardHome.tsx`.
- AI board aggregation belongs in `modules/ai/server/analysis.ts` because redaction depends on existing AI analysis scope rules.
## Data Flow
1. Dashboard page loads auth context and checks whether any visible dashboard section is permitted.
2. It conditionally calls existing operations for core care/health/emergency and collaboration modules.
3. It calls a new AI analysis listing function only when `ai:read` is present.
4. The AI service lists organization-scoped `elder_ai_analyses`, joins elders for display names, and maps each row through `canViewAnalysisScopes`.
5. The dashboard component receives only serializable data and renders queue cards/summary cards from that data.
## Seeding Strategy
Existing `seedDefaultWorkspaceData` exits early when core room/bed/elder/admission rows already exist. That protected existing workspaces, but it skipped collaboration seed records added later. Add a separate idempotent collaboration seeding helper that:
- reads existing rows by stable natural keys per table;
- inserts only missing default rows;
- resolves dependencies from existing or newly inserted rows;
- never updates or deletes existing operator data;
- runs both for fully new workspaces and for already-initialized workspaces.
Stable keys:
- devices: `code`
- tickets: `title`
- notices: `title`
- alert rules: `name`
- alert triggers: `title`
- family contacts: `elderId + name`
- family visits: `elderId + contactId + scheduled offset/status/notes` is not stable across reruns, so use `elderId + contactId + notes` for default records
- family feedback: `elderId + contactId + content`
## Permission Model
- Dashboard visibility expands to collaboration and AI read permissions.
- Collaboration sections call server operations only when their read permission is present.
- AI board calls the aggregation function only with `ai:read`.
- Redaction uses the existing `canViewAnalysisScopes` and `getPermissionForDataScope` rules, so a user may see that an analysis exists without seeing restricted result details.
## UI Shape
- Keep `DashboardHome` as a single Server-rendered component with small local helper render functions.
- Add collaboration queue cards for device tickets, notices, alert triggers, and family items.
- Add an AI analysis board card with risk/status badges, data scopes, created time, and a link back to the elder workspace.
- Empty states must be honest: no generated counts or placeholder rows.
## Compatibility
No schema migration is required. The seeding helper is additive and only inserts missing default data.
## Rollback
Rollback is limited to the AI aggregation helper, dashboard props/rendering, dashboard page conditional loads, and the additive seed helper. Existing collaboration CRUD modules remain untouched unless tests expose a direct integration bug.

View File

@@ -0,0 +1,5 @@
{"file":".trellis/spec/backend/type-safety.md","reason":"Backend service results, scope narrowing, and no non-null assertions."}
{"file":".trellis/spec/frontend/components.md","reason":"Dashboard Server Component data loading, Kumo UI adapter use, and no fake operational data."}
{"file":".trellis/spec/shared/typescript.md","reason":"Shared TypeScript constraints for exported types and discriminated unions."}
{"file":".trellis/spec/shared/code-quality.md","reason":"No any, no non-null assertions, import ordering, and test expectations."}
{"file":".trellis/spec/guides/pre-implementation-checklist.md","reason":"Cross-layer readiness checks before dashboard and seed changes."}

View File

@@ -0,0 +1,34 @@
# Implementation Plan
## 1. Planning and Context
- [x] Read Trellis workflow context and relevant backend/frontend/shared specs.
- [x] Inspect existing collaboration modules, dashboard page/component, AI analysis service, permissions, and seed data.
- [x] Persist PRD, design, implementation plan, and curated spec manifests.
## 2. Seeding
- [x] Extract idempotent collaboration seed helper from the full-workspace seed path.
- [x] Call the helper for both existing core workspaces and newly seeded workspaces.
- [x] Preserve existing operator rows and insert only missing default records.
## 3. AI Analysis Board
- [x] Add an organization-scoped AI board listing function that joins elder names and redacts restricted analysis rows.
- [x] Add serializable dashboard board item types.
- [x] Keep existing elder-specific analysis APIs unchanged.
## 4. Dashboard Integration
- [x] Expand dashboard permission gate to collaboration and AI read permissions.
- [x] Conditionally load devices, notices, alerts, family, and AI board data by permission.
- [x] Render collaboration cards and AI analysis board from persisted data only.
- [x] Preserve slug-aware links via `getWorkspaceHref`.
## 5. Verification
- [x] Add focused tests for collaboration seed idempotency and AI board redaction.
- [x] Run focused tests covering changed code.
- [x] Run `pnpm type-check`.
- [x] Smoke-test the dashboard route when feasible via `pnpm build` route compilation.
- [x] Add latency controls for AI provider timeout, token cap, retry count, and sanitized timeout failure history.

View File

@@ -0,0 +1,30 @@
# Complete operations modules and AI analysis board
## Goal
Finish the currently visible operations surface by making collaboration modules fully reachable from the workspace/dashboard and adding a real persisted AI analysis board that summarizes elder AI analysis history without fabricating data.
## Requirements
- Keep the existing persisted collaboration modules for devices, notices, alerts, and family visible through permission-aware workspace navigation and dashboard entry points.
- Seed collaboration-module records idempotently for already-initialized local/demo organizations without overwriting operator-created records.
- Show an AI analysis board from persisted `elder_ai_analyses` rows, joined to elder names, with permission-aware redaction for unavailable data scopes.
- Restrict AI board access to users with `ai:read`; never expose analysis content when underlying data-scope permissions are missing.
- Preserve organization scoping for all data loads and route links, including slug-aware `/app/{organizationSlug}/...` links.
- Reuse existing module server operations and UI adapters; do not add new dependencies or fake/static operational data.
## Acceptance Criteria
- [x] Existing collaboration modules remain data-backed and reachable from unscoped and organization-scoped workspace routes.
- [x] Existing workspaces with core resident data but no collaboration data receive missing default devices, notices, alerts, and family records on seed rerun.
- [x] The dashboard can be viewed by users with collaboration or AI read permissions, not only core care/facility permissions.
- [x] The dashboard renders collaboration queues/metrics only from persisted server queries and hides unavailable sections by permission.
- [x] The dashboard renders an AI analysis board sourced from persisted analysis history when `ai:read` is present.
- [x] AI analysis board items redact result content when `canViewAnalysisScopes` denies one or more stored data scopes.
- [x] Focused tests cover idempotent collaboration seeding and AI board redaction behavior.
- [x] `pnpm type-check` and focused tests pass.
- [x] Elder AI generation uses bounded provider timeout, token, and retry controls so slow providers fail with sanitized timeout history instead of hanging indefinitely.
## Notes
- Out of scope: background alert-rule execution, family portal/authentication, AI recommendation/business-action generation changes, and new database schema.

View File

@@ -0,0 +1,26 @@
{
"id": "complete-ops-ai-board",
"name": "complete-ops-ai-board",
"title": "Complete operations modules and AI analysis board",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-08",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -1,5 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listAiAnalysisBoard } from "@/modules/ai/server/analysis";
import { listAlertCenterData } from "@/modules/alerts/server/operations";
import { getCurrentAuthContext } from "@/modules/core/server/auth"; import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { import {
listOperationalAdmissions, listOperationalAdmissions,
@@ -11,12 +13,27 @@ import { hasPermission } from "@/modules/core/server/permissions";
import type { BedStatus, Permission } from "@/modules/core/types"; import type { BedStatus, Permission } from "@/modules/core/types";
import { listCareExecutionData } from "@/modules/care/server/operations"; import { listCareExecutionData } from "@/modules/care/server/operations";
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome"; import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
import { listDeviceOperationsData } from "@/modules/devices/server/operations";
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations"; import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
import { listFamilyServiceData } from "@/modules/family/server/operations";
import { listHealthAdminData } from "@/modules/health/server/operations"; import { listHealthAdminData } from "@/modules/health/server/operations";
import { listNoticeCenterData } from "@/modules/notices/server/operations";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"]; const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
const DASHBOARD_PERMISSIONS: Permission[] = ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read"]; const DASHBOARD_PERMISSIONS: Permission[] = [
"elder:read",
"facility:read",
"admission:read",
"incident:read",
"care:read",
"health:read",
"device:read",
"notice:read",
"alert:read",
"family:read",
"ai:read",
];
export default async function DashboardPage(): Promise<React.ReactElement> { export default async function DashboardPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext(); const context = await getCurrentAuthContext();
@@ -30,18 +47,31 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
redirect(getWorkspaceHref(context.organization?.slug, "/family")); redirect(getWorkspaceHref(context.organization?.slug, "/family"));
} }
const canReadElders = hasPermission(context.permissions, "elder:read");
const canReadFacility = hasPermission(context.permissions, "facility:read");
const canReadAdmissions = hasPermission(context.permissions, "admission:read");
const canReadCare = hasPermission(context.permissions, "care:read"); const canReadCare = hasPermission(context.permissions, "care:read");
const canReadHealth = hasPermission(context.permissions, "health:read"); const canReadHealth = hasPermission(context.permissions, "health:read");
const canReadIncidents = hasPermission(context.permissions, "incident:read"); const canReadIncidents = hasPermission(context.permissions, "incident:read");
const canReadDevices = hasPermission(context.permissions, "device:read");
const canReadNotices = hasPermission(context.permissions, "notice:read");
const canReadAlerts = hasPermission(context.permissions, "alert:read");
const canReadFamily = hasPermission(context.permissions, "family:read");
const canReadAi = hasPermission(context.permissions, "ai:read");
const [elders, beds, admissions, incidents, careData, healthData, emergencyData] = await Promise.all([ const [elders, beds, admissions, incidents, careData, healthData, emergencyData, deviceData, noticeData, alertData, familyData, aiBoardResult] = await Promise.all([
listOperationalElders(organizationId), organizationId && canReadElders ? listOperationalElders(organizationId) : [],
listOperationalBeds(organizationId), organizationId && (canReadFacility || canReadAdmissions) ? listOperationalBeds(organizationId) : [],
listOperationalAdmissions(organizationId), organizationId && canReadAdmissions ? listOperationalAdmissions(organizationId) : [],
listOperationalIncidents(organizationId), organizationId && canReadIncidents ? listOperationalIncidents(organizationId) : [],
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined, organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined, organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined,
organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined, organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined,
organizationId && canReadDevices ? listDeviceOperationsData(organizationId) : undefined,
organizationId && canReadNotices ? listNoticeCenterData(organizationId, context.account.id) : undefined,
organizationId && canReadAlerts ? listAlertCenterData(organizationId) : undefined,
organizationId && canReadFamily ? listFamilyServiceData(organizationId) : undefined,
canReadAi ? listAiAnalysisBoard(context, 6) : undefined,
]); ]);
const activeElders = elders.filter((elder) => elder.status === "active").length; const activeElders = elders.filter((elder) => elder.status === "active").length;
@@ -56,31 +86,50 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0; const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
const openEmergency = emergencyData?.metrics.open ?? 0; const openEmergency = emergencyData?.metrics.open ?? 0;
const criticalEmergency = emergencyData?.metrics.critical ?? 0; const criticalEmergency = emergencyData?.metrics.critical ?? 0;
const openDeviceTickets = deviceData?.metrics.openTickets ?? 0;
const urgentDeviceTickets = deviceData?.metrics.urgentTickets ?? 0;
const publishedNotices = noticeData?.metrics.published ?? 0;
const unreadNotices = noticeData?.metrics.unread ?? 0;
const openAlertTriggers = alertData?.metrics.openTriggers ?? 0;
const criticalAlertTriggers = alertData?.metrics.criticalTriggers ?? 0;
const pendingFamilyVisits = familyData?.metrics.pendingVisits ?? 0;
const openFamilyFeedback = familyData?.metrics.openFeedback ?? 0;
const aiBoardItems = aiBoardResult?.success ? aiBoardResult.data.items : undefined;
const metrics: DashboardMetric[] = [ const metrics: DashboardMetric[] = [
{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" }, ...(canReadElders ? [{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" as const }] : []),
{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" }, ...(canReadFacility || canReadAdmissions ? [{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" as const }] : []),
{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" }, ...(canReadCare ? [{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" as const }] : []),
{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" }, ...(canReadHealth ? [{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" as const }] : []),
{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" }, ...(canReadAdmissions ? [{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" as const }] : []),
{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" }, ...(canReadIncidents ? [{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" as const }] : []),
...(canReadDevices ? [{ label: "设备工单", value: String(openDeviceTickets), trend: `紧急 ${urgentDeviceTickets}`, icon: "devices" as const }] : []),
...(canReadNotices ? [{ label: "公告通知", value: String(publishedNotices), trend: `未读 ${unreadNotices}`, icon: "notices" as const }] : []),
...(canReadAlerts ? [{ label: "规则预警", value: String(openAlertTriggers), trend: `关键 ${criticalAlertTriggers}`, icon: "alerts" as const }] : []),
...(canReadFamily ? [{ label: "家属服务", value: String(pendingFamilyVisits + openFamilyFeedback), trend: `探访 ${pendingFamilyVisits} / 反馈 ${openFamilyFeedback}`, icon: "family" as const }] : []),
...(canReadAi && aiBoardItems ? [{ label: "智能分析", value: String(aiBoardItems.length), trend: "已保存分析历史", icon: "ai" as const }] : []),
]; ];
const occupancyData = BED_STATUSES.map((status) => ({ const occupancyData = canReadFacility || canReadAdmissions
? BED_STATUSES.map((status) => ({
label: status, label: status,
count: beds.filter((bed) => bed.status === status).length, count: beds.filter((bed) => bed.status === status).length,
})); }))
: undefined;
const recentAdmissions = admissions.slice(0, 6).map((admission) => ({ const recentAdmissions = canReadAdmissions
? admissions.slice(0, 6).map((admission) => ({
id: admission.id, id: admission.id,
elderName: admission.elderName, elderName: admission.elderName,
bedLabel: `${admission.roomName}-${admission.bedCode}`, bedLabel: `${admission.roomName}-${admission.bedCode}`,
status: admission.status, status: admission.status,
admittedAt: admission.admittedAt, admittedAt: admission.admittedAt,
dischargedAt: admission.dischargedAt, dischargedAt: admission.dischargedAt,
})); }))
: undefined;
const visibleIncidents = incidents const visibleIncidents = canReadIncidents
? incidents
.filter((incident) => incident.status !== "closed") .filter((incident) => incident.status !== "closed")
.slice(0, 6) .slice(0, 6)
.map((incident) => ({ .map((incident) => ({
@@ -90,8 +139,9 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
title: incident.title, title: incident.title,
source: incident.source, source: incident.source,
createdAt: incident.createdAt, createdAt: incident.createdAt,
})); }))
const careTasks = (careData?.tasks ?? []) : undefined;
const careTasks = careData?.tasks
.filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high") .filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high")
.slice(0, 6) .slice(0, 6)
.map((task) => ({ .map((task) => ({
@@ -103,7 +153,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
status: task.status, status: task.status,
title: task.title, title: task.title,
})); }));
const healthReviews = (healthData?.reviews ?? []) const healthReviews = healthData?.reviews
.filter((review) => review.status === "pending" || review.severity === "critical") .filter((review) => review.status === "pending" || review.severity === "critical")
.slice(0, 6) .slice(0, 6)
.map((review) => ({ .map((review) => ({
@@ -113,7 +163,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
status: review.status, status: review.status,
title: review.title, title: review.title,
})); }));
const emergencyIncidents = (emergencyData?.incidents ?? []) const emergencyIncidents = emergencyData?.incidents
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical") .filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
.slice(0, 6) .slice(0, 6)
.map((incident) => ({ .map((incident) => ({
@@ -124,18 +174,76 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
source: incident.source, source: incident.source,
createdAt: incident.createdAt, createdAt: incident.createdAt,
})); }));
const deviceTickets = deviceData?.tickets
.filter((ticket) => ticket.status === "open" || ticket.status === "assigned" || ticket.priority === "urgent" || ticket.priority === "high")
.slice(0, 6)
.map((ticket) => ({
id: ticket.id,
deviceName: ticket.deviceName,
priority: ticket.priority,
status: ticket.status,
title: ticket.title,
updatedAt: ticket.updatedAt,
}));
const notices = noticeData?.notices.slice(0, 6).map((notice) => ({
id: notice.id,
audience: notice.audience,
status: notice.status,
title: notice.title,
updatedAt: notice.updatedAt,
}));
const alertTriggers = alertData?.triggers
.filter((trigger) => trigger.status === "open" || trigger.status === "acknowledged")
.slice(0, 6)
.map((trigger) => ({
id: trigger.id,
elderName: trigger.elderName,
status: trigger.status,
title: trigger.title,
updatedAt: trigger.updatedAt,
}));
const familyVisits = familyData?.visits
.filter((visit) => visit.status === "requested" || visit.status === "approved")
.slice(0, 3)
.map((visit) => ({
id: visit.id,
contactName: visit.contactName,
elderName: visit.elderName,
scheduledAt: visit.scheduledAt,
status: visit.status,
}));
const familyFeedback = familyData?.feedback
.filter((feedback) => feedback.status === "open" || feedback.status === "in_progress")
.slice(0, 3)
.map((feedback) => ({
id: feedback.id,
elderName: feedback.elderName,
status: feedback.status,
updatedAt: feedback.updatedAt,
}));
return ( return (
<DashboardHome <DashboardHome
alertTriggers={alertTriggers}
alertsHref={getWorkspaceHref(context.organization?.slug, "/alerts")}
aiBoardItems={canReadAi ? aiBoardItems ?? [] : undefined}
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")} bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
careHref={getWorkspaceHref(context.organization?.slug, "/care")} careHref={getWorkspaceHref(context.organization?.slug, "/care")}
careTasks={careTasks} careTasks={careTasks}
deviceTickets={deviceTickets}
devicesHref={getWorkspaceHref(context.organization?.slug, "/devices")}
eldersHref={getWorkspaceHref(context.organization?.slug, "/elders")}
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")} emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
emergencyIncidents={emergencyIncidents} emergencyIncidents={emergencyIncidents}
familyFeedback={familyFeedback}
familyHref={getWorkspaceHref(context.organization?.slug, "/family")}
familyVisits={familyVisits}
healthHref={getWorkspaceHref(context.organization?.slug, "/health")} healthHref={getWorkspaceHref(context.organization?.slug, "/health")}
healthReviews={healthReviews} healthReviews={healthReviews}
incidents={visibleIncidents} incidents={visibleIncidents}
metrics={metrics} metrics={metrics}
notices={notices}
noticesHref={getWorkspaceHref(context.organization?.slug, "/notices")}
occupancyData={occupancyData} occupancyData={occupancyData}
recentAdmissions={recentAdmissions} recentAdmissions={recentAdmissions}
/> />

View File

@@ -1,11 +1,10 @@
import { ChatOpenAI } from "@langchain/openai"; import { ChatOpenAI } from "@langchain/openai";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
import { getAiRuntimeConfig } from "@/modules/ai/server/config"; import { getAiRuntimeConfig } from "@/modules/ai/server/config";
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context"; import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
import { buildElderAiContext } from "@/modules/ai/server/elder-context"; import { buildElderAiContext } from "@/modules/ai/server/elder-context";
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis"; import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import { retrieveKnowledge } from "@/modules/ai/server/knowledge"; import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types"; import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit"; import { recordAuditLog } from "@/modules/core/server/audit";
@@ -65,6 +64,11 @@ type AnalysisRow = {
updatedAt: Date; updatedAt: Date;
}; };
type ElderNameRow = {
id: string;
name: string;
};
type AnalysisInsertPayload = { type AnalysisInsertPayload = {
organizationId?: unknown; organizationId?: unknown;
elderId?: unknown; elderId?: unknown;
@@ -90,14 +94,17 @@ type ReturnlessMock = ReturnlessFunction & {
type ReturnlessFunction = (...args: unknown[]) => unknown; type ReturnlessFunction = (...args: unknown[]) => unknown;
const runtimeConfig: AiRuntimeConfigResult = { const runtimeConfig = {
success: true, success: true,
config: { config: {
baseUrl: "https://ai.example.test/v1", baseUrl: "https://ai.example.test/v1",
apiKey: "test-api-key", apiKey: "test-api-key",
chatModel: "gpt-test", chatModel: "gpt-test",
requestTimeoutMs: 12_345,
maxTokens: 901,
maxRetries: 4,
}, },
}; } as const;
const account: AuthContext["account"] = { const account: AuthContext["account"] = {
id: "account-1", id: "account-1",
@@ -258,8 +265,9 @@ function createAnalysisRow(values: AnalysisInsertPayload, index: number): Analys
}; };
} }
function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFake { function createDatabaseFake(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseFake {
const insertedValues: AnalysisInsertPayload[] = []; const insertedValues: AnalysisInsertPayload[] = [];
const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
const insert = vi.fn(() => ({ const insert = vi.fn(() => ({
values: vi.fn((values: AnalysisInsertPayload) => { values: vi.fn((values: AnalysisInsertPayload) => {
insertedValues.push(values); insertedValues.push(values);
@@ -268,15 +276,23 @@ function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFak
}; };
}), }),
})); }));
const select = vi.fn(() => ({ const select = vi.fn((selection?: unknown) => {
const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection;
return {
from: vi.fn(() => ({ from: vi.fn(() => ({
where: vi.fn(() => ({ where: vi.fn(() => {
if (selectsElderNames) {
return elderRows;
}
return {
orderBy: vi.fn(() => ({ orderBy: vi.fn(() => ({
limit: vi.fn(async () => selectRows), limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)),
})), })),
};
}),
})), })),
})), };
})); });
return { insertedValues, insert, select }; return { insertedValues, insert, select };
} }
@@ -313,6 +329,52 @@ beforeEach(() => {
}); });
describe("elder AI analysis service", () => { describe("elder AI analysis service", () => {
it("constructs ChatOpenAI with latency controls from AI runtime config", async () => {
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
expect(result.success).toBe(true);
expect(ChatOpenAI).toHaveBeenCalledWith(expect.objectContaining({
apiKey: "test-api-key",
model: "gpt-test",
timeout: 12_345,
maxTokens: 901,
maxRetries: 4,
configuration: { baseURL: "https://ai.example.test/v1" },
}));
});
it.each([
{
name: "AbortError",
error: Object.assign(new Error("aborted request with sk-live-secret and resident prompt"), { name: "AbortError" }),
},
{
name: "timeout-like provider message",
error: new Error("request timed out after 12345ms with sk-live-secret and resident prompt"),
},
])("maps $name model failures to sanitized timeout failed history", async ({ error }) => {
const database = createDatabaseFake();
useDatabase(database);
chatMocks.invoke.mockRejectedValue(error);
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
expect(result).toEqual({ success: false, reason: "AI 服务响应超时", status: 502 });
expect(database.insertedValues).toEqual([
expect.objectContaining({
status: "failed",
dataScopes: ["elder", "health"],
errorCategory: "timeout",
errorReason: "AI 服务响应超时",
}),
]);
const persistedFailure = JSON.stringify(database.insertedValues[0]);
expect(persistedFailure).not.toContain("sk-live-secret");
expect(persistedFailure).not.toContain("resident prompt");
expect(persistedFailure).not.toContain("Night wandering");
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务响应超时" }));
});
it("saves sanitized failed history when AI runtime config is missing", async () => { it("saves sanitized failed history when AI runtime config is missing", async () => {
const database = createDatabaseFake(); const database = createDatabaseFake();
useDatabase(database); useDatabase(database);
@@ -442,6 +504,130 @@ describe("elder AI analysis service", () => {
}); });
}); });
it("lists board analysis rows with elder display names and permitted result content", async () => {
const analysisResult = createModelOutput({
overallRiskLevel: "high",
summary: "Night wandering requires a care-plan review.",
});
const completedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-2",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder", "health"],
resultJson: analysisResult,
citationsJson: analysisResult.citations,
modelSummaryJson: analysisResult.modelSummary,
}, 1);
useDatabase(createDatabaseFake([completedRow], [{ id: "elder-2", name: "李建国" }]));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 5);
expect(result).toEqual({
success: true,
data: {
items: [
expect.objectContaining({
id: "analysis-2",
elderId: "elder-2",
elderName: "李建国",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T01:00:00.000Z",
restricted: false,
result: analysisResult,
}),
],
},
});
});
it("redacts board analysis rows when a stored data scope is not permitted", async () => {
const restrictedResult = createModelOutput({
summary: "Health details should not be visible without health permission.",
});
const restrictedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-3",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder", "health"],
resultJson: restrictedResult,
citationsJson: restrictedResult.citations,
modelSummaryJson: restrictedResult.modelSummary,
errorCategory: "provider_error",
errorReason: "provider detail should stay hidden",
}, 2);
useDatabase(createDatabaseFake([restrictedRow], [{ id: "elder-3", name: "周玉珍" }]));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 5);
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.items).toEqual([
{
id: "analysis-3",
elderId: "elder-3",
elderName: "周玉珍",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T02:00:00.000Z",
restricted: true,
},
]);
expect(result.data.items[0]).not.toHaveProperty("result");
expect(result.data.items[0]).not.toHaveProperty("errorCategory");
expect(result.data.items[0]).not.toHaveProperty("errorReason");
});
it("returns board rows in newest-first order up to the requested limit", async () => {
const newestRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-4",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
resultJson: createModelOutput({ summary: "Newest analysis" }),
}, 3);
const olderRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
resultJson: createModelOutput({ summary: "Older analysis" }),
}, 0);
const middleRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-2",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
resultJson: createModelOutput({ summary: "Middle analysis" }),
}, 1);
useDatabase(createDatabaseFake(
[olderRow, newestRow, middleRow],
[
{ id: "elder-1", name: "王阿姨" },
{ id: "elder-2", name: "李建国" },
{ id: "elder-4", name: "陈桂兰" },
],
));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 2);
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.items.map((item) => ({ id: item.id, elderName: item.elderName, createdAt: item.createdAt }))).toEqual([
{ id: "analysis-4", elderName: "陈桂兰", createdAt: "2026-07-02T03:00:00.000Z" },
{ id: "analysis-2", elderName: "李建国", createdAt: "2026-07-02T01:00:00.000Z" },
]);
});
it("rejects model output that cites IDs outside resident and knowledge citations", async () => { it("rejects model output that cites IDs outside resident and knowledge citations", async () => {
const database = createDatabaseFake(); const database = createDatabaseFake();
useDatabase(database); useDatabase(database);

View File

@@ -9,6 +9,7 @@ import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
import type { import type {
AiCitation, AiCitation,
AiErrorCategory, AiErrorCategory,
ElderAiAnalysisBoardItem,
ElderAiAnalysisHistoryItem, ElderAiAnalysisHistoryItem,
ElderAiAnalysisOutput, ElderAiAnalysisOutput,
} from "@/modules/ai/types"; } from "@/modules/ai/types";
@@ -19,19 +20,28 @@ import {
} from "@/modules/ai/types"; } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit"; import { recordAuditLog } from "@/modules/core/server/audit";
import { getDatabase } from "@/modules/core/server/db"; import { getDatabase } from "@/modules/core/server/db";
import { elderAiAnalyses } from "@/modules/core/server/schema"; import { elderAiAnalyses, elders } from "@/modules/core/server/schema";
import type { AuthContext } from "@/modules/core/types"; import type { AuthContext } from "@/modules/core/types";
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number }; type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
type AnalysisRow = typeof elderAiAnalyses.$inferSelect; type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
function createChatModel(config: { baseUrl: string; apiKey: string; chatModel: string }) { function createChatModel(config: {
baseUrl: string;
apiKey: string;
chatModel: string;
requestTimeoutMs: number;
maxTokens: number;
maxRetries: number;
}) {
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined; const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
return new ChatOpenAI({ return new ChatOpenAI({
apiKey: config.apiKey, apiKey: config.apiKey,
model: config.chatModel, model: config.chatModel,
maxTokens: 1800, maxTokens: config.maxTokens,
maxRetries: config.maxRetries,
temperature: 0.2, temperature: 0.2,
timeout: config.requestTimeoutMs,
configuration, configuration,
}); });
} }
@@ -41,9 +51,22 @@ function toIsoString(value: Date): string {
} }
function mapErrorCategory(error: unknown): AiErrorCategory { function mapErrorCategory(error: unknown): AiErrorCategory {
if (error instanceof Error && error.name.toLowerCase().includes("timeout")) { if (!(error instanceof Error)) {
return "provider_error";
}
const name = error.name.toLowerCase();
const message = error.message.toLowerCase();
if (
name.includes("abort") ||
name.includes("timeout") ||
message.includes("abort") ||
message.includes("timeout") ||
message.includes("timed out")
) {
return "timeout"; return "timeout";
} }
return "provider_error"; return "provider_error";
} }
@@ -202,6 +225,48 @@ export async function listElderAiAnalyses(
}; };
} }
function rowToBoardItem(row: AnalysisRow, elderName: string, permissions: AuthContext["permissions"]): ElderAiAnalysisBoardItem {
return {
elderName,
...rowToHistoryItem(row, permissions),
};
}
export async function listAiAnalysisBoard(
context: AuthContext,
limit = 8,
): Promise<ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>> {
if (!context.permissions.includes("ai:read")) {
return { success: false, reason: "无权查看 AI 分析", status: 403 };
}
const organizationId = context.organization?.id;
if (!organizationId) {
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
}
const normalizedLimit = Number.isFinite(limit) ? Math.trunc(limit) : 8;
const rowLimit = Math.min(20, Math.max(1, normalizedLimit));
const database = getDatabase();
const [rows, elderRows] = await Promise.all([
database
.select()
.from(elderAiAnalyses)
.where(eq(elderAiAnalyses.organizationId, organizationId))
.orderBy(desc(elderAiAnalyses.createdAt))
.limit(rowLimit),
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
]);
const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name]));
return {
success: true,
data: {
items: rows.map((row) => rowToBoardItem(row, elderNameById.get(row.elderId) ?? "未知老人", context.permissions)),
},
};
}
export async function generateElderAiAnalysis( export async function generateElderAiAnalysis(
context: AuthContext, context: AuthContext,
elderId: string, elderId: string,

View File

@@ -3,6 +3,9 @@ export type AiRuntimeConfig = {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
chatModel: string; chatModel: string;
requestTimeoutMs: number;
maxTokens: number;
maxRetries: number;
}; };
export type AiRuntimeConfigResult = export type AiRuntimeConfigResult =
@@ -10,11 +13,28 @@ export type AiRuntimeConfigResult =
| { success: false; reason: string }; | { success: false; reason: string };
const DEFAULT_CHAT_MODEL = "gpt-4.1-mini"; const DEFAULT_CHAT_MODEL = "gpt-4.1-mini";
const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
const DEFAULT_MAX_TOKENS = 1_000;
const DEFAULT_MAX_RETRIES = 0;
function readOptionalEnv(name: string): string { function readOptionalEnv(name: string): string {
return process.env[name]?.trim() ?? ""; return process.env[name]?.trim() ?? "";
} }
function readBoundedIntegerEnv(name: string, fallback: number, min: number, max: number): number {
const rawValue = readOptionalEnv(name);
if (!rawValue) {
return fallback;
}
const value = Number(rawValue);
if (!Number.isInteger(value) || value < min || value > max) {
return fallback;
}
return value;
}
export function getAiRuntimeConfig(): AiRuntimeConfigResult { export function getAiRuntimeConfig(): AiRuntimeConfigResult {
const apiKey = readOptionalEnv("AI_API_KEY"); const apiKey = readOptionalEnv("AI_API_KEY");
if (!apiKey) { if (!apiKey) {
@@ -27,6 +47,10 @@ export function getAiRuntimeConfig(): AiRuntimeConfigResult {
baseUrl: readOptionalEnv("AI_BASE_URL"), baseUrl: readOptionalEnv("AI_BASE_URL"),
apiKey, apiKey,
chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL, chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL,
requestTimeoutMs: readBoundedIntegerEnv("AI_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, 1_000, 120_000),
maxTokens: readBoundedIntegerEnv("AI_MAX_TOKENS", DEFAULT_MAX_TOKENS, 256, 2_000),
maxRetries: readBoundedIntegerEnv("AI_MAX_RETRIES", DEFAULT_MAX_RETRIES, 0, 5),
}, },
}; };
} }

View File

@@ -93,6 +93,10 @@ export type ElderAiAnalysisHistoryItem = {
errorReason?: string; errorReason?: string;
}; };
export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & {
elderName: string;
};
export type KnowledgeEntryInput = { export type KnowledgeEntryInput = {
scope: AiKnowledgeScope; scope: AiKnowledgeScope;
organizationId?: string; organizationId?: string;

View File

@@ -1,9 +1,19 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types"; import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
import { getDatabase, type AppDatabase } from "@/modules/core/server/db";
import { import {
DEFAULT_AI_KNOWLEDGE_ENTRIES, DEFAULT_AI_KNOWLEDGE_ENTRIES,
DEFAULT_ALERT_RULES,
DEFAULT_ALERT_TRIGGERS,
DEFAULT_DEVICE_ASSETS,
DEFAULT_FAMILY_CONTACTS,
DEFAULT_FAMILY_FEEDBACK,
DEFAULT_FAMILY_VISITS,
DEFAULT_MAINTENANCE_TICKETS,
DEFAULT_NOTICES,
DEFAULT_PREPARED_AI_ANALYSES, DEFAULT_PREPARED_AI_ANALYSES,
seedDefaultCollaborationWorkspaceData,
} from "@/modules/core/server/default-workspace-data"; } from "@/modules/core/server/default-workspace-data";
const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i; const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i;
@@ -13,6 +23,258 @@ vi.mock("@/modules/core/server/db", () => ({
getDatabase: vi.fn(), getDatabase: vi.fn(),
})); }));
function expectStableUniqueNaturalKeys<T>(items: T[], getKey: (item: T) => string, expectedKeys: string[]): void {
const keys = items.map(getKey);
expect(keys).toEqual(expectedKeys);
expect(new Set(keys)).toHaveLength(items.length);
for (const key of keys) {
expect(key.trim()).toBe(key);
expect(key).not.toBe("");
}
}
type SeedInsertRow = Record<string, unknown>;
function isSeedInsertRow(value: unknown): value is SeedInsertRow {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function toSeedInsertRows(values: unknown): SeedInsertRow[] {
return Array.isArray(values) ? values.filter(isSeedInsertRow) : [];
}
function rowsWithKey(rows: SeedInsertRow[], key: string): SeedInsertRow[] {
return rows.filter((row) => key in row);
}
function createCollaborationSeedDatabaseMock(params: {
missingDeviceCode: string;
missingTicketTitle: string;
missingNoticeTitle: string;
missingAlertRuleName: string;
missingAlertTriggerTitle: string;
missingFamilyContactName: string;
missingFamilyVisitNotes: string;
missingFamilyFeedbackContent: string;
}): { database: AppDatabase; insertCalls: Array<{ values: SeedInsertRow[] }> } {
const elderNames = Array.from(
new Set([
...DEFAULT_FAMILY_CONTACTS.map((contact) => contact.elderName),
...DEFAULT_ALERT_TRIGGERS.flatMap((trigger) => (trigger.elderName ? [trigger.elderName] : [])),
]),
);
const elderRows = elderNames.map((name, index) => ({ id: `elder-${index}`, name }));
const getElderId = (name: string): string => {
const elder = elderRows.find((row) => row.name === name);
if (!elder) {
throw new Error(`Missing test elder row for ${name}`);
}
return elder.id;
};
const allFamilyContactRows = DEFAULT_FAMILY_CONTACTS.map((contact, index) => ({
id: `contact-${index}`,
elderId: getElderId(contact.elderName),
name: contact.name,
}));
const getContactId = (elderName: string, contactName: string): string => {
const elderId = getElderId(elderName);
const contact = allFamilyContactRows.find((row) => row.elderId === elderId && row.name === contactName);
if (!contact) {
throw new Error(`Missing test contact row for ${elderName}/${contactName}`);
}
return contact.id;
};
const selectResults = [
elderRows,
DEFAULT_DEVICE_ASSETS.filter((device) => device.code !== params.missingDeviceCode).map((device) => ({ id: `device-${device.code}`, code: device.code })),
DEFAULT_MAINTENANCE_TICKETS.filter((ticket) => ticket.title !== params.missingTicketTitle).map((ticket) => ({ title: ticket.title })),
DEFAULT_NOTICES.filter((notice) => notice.title !== params.missingNoticeTitle).map((notice) => ({ title: notice.title })),
DEFAULT_ALERT_RULES.filter((rule) => rule.name !== params.missingAlertRuleName).map((rule) => ({ id: `rule-${rule.name}`, name: rule.name })),
DEFAULT_ALERT_TRIGGERS.filter((trigger) => trigger.title !== params.missingAlertTriggerTitle).map((trigger) => ({ title: trigger.title })),
allFamilyContactRows.filter((contact) => contact.name !== params.missingFamilyContactName),
DEFAULT_FAMILY_VISITS.filter((visit) => visit.notes !== params.missingFamilyVisitNotes).map((visit) => ({
elderId: getElderId(visit.elderName),
contactId: getContactId(visit.elderName, visit.contactName),
notes: visit.notes,
})),
DEFAULT_FAMILY_FEEDBACK.filter((feedback) => feedback.content !== params.missingFamilyFeedbackContent).map((feedback) => ({
elderId: getElderId(feedback.elderName),
contactId: getContactId(feedback.elderName, feedback.contactName),
content: feedback.content,
})),
];
let selectIndex = 0;
const insertCalls: Array<{ values: SeedInsertRow[] }> = [];
const transaction = {
insert: vi.fn(() => ({
values: vi.fn((values: unknown) => {
const rows = toSeedInsertRows(values);
insertCalls.push({ values: rows });
return {
returning: vi.fn(() => rows.map((row, index) => ({ ...row, id: `inserted-${index}` }))),
};
}),
})),
};
const database = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => {
const result = selectResults[selectIndex] ?? [];
selectIndex += 1;
return result;
}),
})),
})),
transaction: vi.fn(async (callback: (transactionClient: typeof transaction) => Promise<void> | void) => {
await callback(transaction);
}),
};
return { database: database as unknown as AppDatabase, insertCalls };
}
describe("default collaboration seed data", () => {
it("keeps idempotent seed natural keys stable, populated, and unique per collaboration table", () => {
expectStableUniqueNaturalKeys(DEFAULT_DEVICE_ASSETS, (device) => device.code, [
"DEV-CALL-A102-2",
"DEV-SPO2-S101",
"DEV-OXY-A302-2",
"DEV-REHAB-R101",
]);
expectStableUniqueNaturalKeys(DEFAULT_MAINTENANCE_TICKETS, (ticket) => ticket.title, [
"床头呼叫器按键失灵",
"氧气接口保养延期",
"康复步行带日常校准",
]);
expectStableUniqueNaturalKeys(DEFAULT_NOTICES, (notice) => notice.title, [
"本周家属探访安排",
"夜间重点巡房提醒",
"设备巡检培训材料",
]);
expectStableUniqueNaturalKeys(DEFAULT_ALERT_RULES, (rule) => rule.name, [
"血氧低值连续告警",
"高优先级护理任务逾期",
"设备维修工单未派单",
]);
expectStableUniqueNaturalKeys(DEFAULT_ALERT_TRIGGERS, (trigger) => trigger.title, [
"S101 血氧低值需复核",
"重点照护夜间巡检逾期",
"氧气接口保养待派单",
]);
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_CONTACTS, (contact) => `${contact.elderName}/${contact.name}`, [
"王桂兰/张敏",
"钱松柏/钱宁",
"林玉琴/林晓",
"赵国华/赵蕾",
]);
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_VISITS, (visit) => `${visit.elderName}/${visit.contactName}/${visit.notes}`, [
"王桂兰/张敏/安排护理楼一层会客区。",
"钱松柏/钱宁/需医生确认夜间血氧情况后再回复。",
"林玉琴/林晓/已完成探访和短住事项确认。",
]);
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_FEEDBACK, (feedback) => `${feedback.elderName}/${feedback.contactName}/${feedback.content}`, [
"赵国华/赵蕾/希望夜间巡房后能同步重点观察结果。",
"林玉琴/林晓/短住房间清洁和呼叫器说明比较清楚。",
]);
});
it("keeps default collaboration records resolvable through their natural-key dependencies", () => {
const deviceCodes = new Set(DEFAULT_DEVICE_ASSETS.map((device) => device.code));
const alertRuleNames = new Set(DEFAULT_ALERT_RULES.map((rule) => rule.name));
const elderNames = new Set(DEFAULT_FAMILY_CONTACTS.map((contact) => contact.elderName));
const contactKeys = new Set(DEFAULT_FAMILY_CONTACTS.map((contact) => `${contact.elderName}/${contact.name}`));
expect(DEFAULT_DEVICE_ASSETS.length).toBeGreaterThanOrEqual(3);
expect(DEFAULT_MAINTENANCE_TICKETS.length).toBeGreaterThanOrEqual(1);
expect(DEFAULT_NOTICES.length).toBeGreaterThanOrEqual(1);
expect(DEFAULT_ALERT_RULES.length).toBeGreaterThanOrEqual(1);
expect(DEFAULT_ALERT_TRIGGERS.length).toBeGreaterThanOrEqual(1);
expect(DEFAULT_FAMILY_CONTACTS.length).toBeGreaterThanOrEqual(1);
expect(DEFAULT_FAMILY_VISITS.length).toBeGreaterThanOrEqual(1);
expect(DEFAULT_FAMILY_FEEDBACK.length).toBeGreaterThanOrEqual(1);
for (const ticket of DEFAULT_MAINTENANCE_TICKETS) {
expect(deviceCodes.has(ticket.deviceCode)).toBe(true);
}
for (const trigger of DEFAULT_ALERT_TRIGGERS) {
expect(alertRuleNames.has(trigger.ruleName)).toBe(true);
if (trigger.elderName) {
expect(elderNames.has(trigger.elderName)).toBe(true);
}
}
for (const visit of DEFAULT_FAMILY_VISITS) {
expect(contactKeys.has(`${visit.elderName}/${visit.contactName}`)).toBe(true);
}
for (const feedback of DEFAULT_FAMILY_FEEDBACK) {
expect(contactKeys.has(`${feedback.elderName}/${feedback.contactName}`)).toBe(true);
}
});
it("keeps user-visible collaboration defaults realistic", () => {
const userVisibleContent = [
...DEFAULT_DEVICE_ASSETS.map((device) => [device.name, device.category, device.location, device.notes].join("\n")),
...DEFAULT_MAINTENANCE_TICKETS.map((ticket) => [ticket.title, ticket.description, ticket.assigneeLabel, ticket.resolutionNotes].join("\n")),
...DEFAULT_NOTICES.map((notice) => [notice.title, notice.content, notice.audience].join("\n")),
...DEFAULT_ALERT_RULES.map((rule) => [rule.name, rule.ruleType, rule.conditionSummary, rule.suggestion].join("\n")),
...DEFAULT_ALERT_TRIGGERS.map((trigger) => [trigger.title, trigger.description, trigger.source, trigger.handlingNotes].join("\n")),
...DEFAULT_FAMILY_CONTACTS.map((contact) => [contact.elderName, contact.name, contact.relationship, contact.phone, contact.notes].join("\n")),
...DEFAULT_FAMILY_VISITS.map((visit) => [visit.elderName, visit.contactName, visit.status, visit.notes].join("\n")),
...DEFAULT_FAMILY_FEEDBACK.map((feedback) => [feedback.elderName, feedback.contactName, feedback.feedbackType, feedback.content, feedback.responseNotes].join("\n")),
].join("\n");
expect(userVisibleContent).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
});
it("inserts only collaboration defaults missing from existing natural-key sets", async () => {
const missingDevice = DEFAULT_DEVICE_ASSETS.find((device) => device.code === "DEV-CALL-A102-2");
const missingTicket = DEFAULT_MAINTENANCE_TICKETS.find((ticket) => ticket.title === "床头呼叫器按键失灵");
const missingNotice = DEFAULT_NOTICES.find((notice) => notice.title === "设备巡检培训材料");
const missingAlertRule = DEFAULT_ALERT_RULES.find((rule) => rule.name === "血氧低值连续告警");
const missingAlertTrigger = DEFAULT_ALERT_TRIGGERS.find((trigger) => trigger.title === "S101 血氧低值需复核");
const missingFamilyContact = DEFAULT_FAMILY_CONTACTS.find((contact) => contact.name === "张敏");
const missingFamilyVisit = DEFAULT_FAMILY_VISITS.find((visit) => visit.notes === "安排护理楼一层会客区。");
const missingFamilyFeedback = DEFAULT_FAMILY_FEEDBACK.find((feedback) => feedback.content === "希望夜间巡房后能同步重点观察结果。");
if (!missingDevice || !missingTicket || !missingNotice || !missingAlertRule || !missingAlertTrigger || !missingFamilyContact || !missingFamilyVisit || !missingFamilyFeedback) {
throw new Error("Missing collaboration seed fixture for behavior test");
}
const { database, insertCalls } = createCollaborationSeedDatabaseMock({
missingDeviceCode: missingDevice.code,
missingTicketTitle: missingTicket.title,
missingNoticeTitle: missingNotice.title,
missingAlertRuleName: missingAlertRule.name,
missingAlertTriggerTitle: missingAlertTrigger.title,
missingFamilyContactName: missingFamilyContact.name,
missingFamilyVisitNotes: missingFamilyVisit.notes,
missingFamilyFeedbackContent: missingFamilyFeedback.content,
});
vi.mocked(getDatabase).mockReturnValue(database);
await seedDefaultCollaborationWorkspaceData("organization-1");
const insertedRows = insertCalls.flatMap((call) => call.values);
expect(insertedRows).toHaveLength(8);
expect(rowsWithKey(insertedRows, "code")).toEqual([expect.objectContaining({ code: missingDevice.code })]);
expect(rowsWithKey(insertedRows, "priority")).toEqual([expect.objectContaining({ title: missingTicket.title })]);
expect(rowsWithKey(insertedRows, "audience")).toEqual([expect.objectContaining({ title: missingNotice.title })]);
expect(rowsWithKey(insertedRows, "conditionSummary")).toEqual([expect.objectContaining({ name: missingAlertRule.name })]);
expect(rowsWithKey(insertedRows, "source")).toEqual([expect.objectContaining({ title: missingAlertTrigger.title })]);
expect(rowsWithKey(insertedRows, "relationship")).toEqual([expect.objectContaining({ name: missingFamilyContact.name })]);
expect(rowsWithKey(insertedRows, "scheduledAt")).toEqual([expect.objectContaining({ notes: missingFamilyVisit.notes })]);
expect(rowsWithKey(insertedRows, "feedbackType")).toEqual([expect.objectContaining({ content: missingFamilyFeedback.content })]);
});
});
describe("default AI workspace seed data", () => { describe("default AI workspace seed data", () => {
it("keeps user-visible AI knowledge content realistic and validator-ready", () => { it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3); expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3);

View File

@@ -979,7 +979,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
}, },
]; ];
const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [ export const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
{ {
name: "A102 床头呼叫器", name: "A102 床头呼叫器",
code: "DEV-CALL-A102-2", code: "DEV-CALL-A102-2",
@@ -1018,7 +1018,7 @@ const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
}, },
]; ];
const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [ export const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
{ {
deviceCode: "DEV-CALL-A102-2", deviceCode: "DEV-CALL-A102-2",
title: "床头呼叫器按键失灵", title: "床头呼叫器按键失灵",
@@ -1048,7 +1048,7 @@ const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
}, },
]; ];
const DEFAULT_NOTICES: SeedNotice[] = [ export const DEFAULT_NOTICES: SeedNotice[] = [
{ {
title: "本周家属探访安排", title: "本周家属探访安排",
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。", content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
@@ -1071,7 +1071,7 @@ const DEFAULT_NOTICES: SeedNotice[] = [
}, },
]; ];
const DEFAULT_ALERT_RULES: SeedAlertRule[] = [ export const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
{ {
name: "血氧低值连续告警", name: "血氧低值连续告警",
ruleType: "health", ruleType: "health",
@@ -1098,7 +1098,7 @@ const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
}, },
]; ];
const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [ export const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
{ {
ruleName: "血氧低值连续告警", ruleName: "血氧低值连续告警",
elderName: "钱松柏", elderName: "钱松柏",
@@ -1130,20 +1130,20 @@ const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
}, },
]; ];
const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [ export const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [
{ elderName: "王桂兰", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active", notes: "主要联系人,每周六探访。" }, { elderName: "王桂兰", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active", notes: "主要联系人,每周六探访。" },
{ elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" }, { elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" }, { elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" },
{ elderName: "赵国华", name: "赵蕾", relationship: "孙女", phone: "13800000016", status: "active", notes: "希望接收护理反馈。" }, { elderName: "赵国华", name: "赵蕾", relationship: "孙女", phone: "13800000016", status: "active", notes: "希望接收护理反馈。" },
]; ];
const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [ export const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [
{ elderName: "王桂兰", contactName: "张敏", scheduledHoursOffset: 30, status: "approved", notes: "安排护理楼一层会客区。" }, { elderName: "王桂兰", contactName: "张敏", scheduledHoursOffset: 30, status: "approved", notes: "安排护理楼一层会客区。" },
{ elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" }, { elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" }, { elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
]; ];
const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [ export const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
{ {
elderName: "赵国华", elderName: "赵国华",
contactName: "赵蕾", contactName: "赵蕾",
@@ -1510,6 +1510,271 @@ async function seedDefaultAiWorkspaceData(organizationId: string): Promise<void>
}); });
} }
function getFamilyContactKey(elderId: string, contactName: string): string {
return `${elderId}:${contactName}`;
}
function getFamilyVisitKey(elderId: string, contactId: string, notes: string): string {
return `${elderId}:${contactId}:${notes}`;
}
function getFamilyFeedbackKey(elderId: string, contactId: string, content: string): string {
return `${elderId}:${contactId}:${content}`;
}
export async function seedDefaultCollaborationWorkspaceData(organizationId: string): Promise<void> {
const database = getDatabase();
const [
elderRows,
existingDeviceRows,
existingTicketRows,
existingNoticeRows,
existingAlertRuleRows,
existingAlertTriggerRows,
existingFamilyContactRows,
existingFamilyVisitRows,
existingFamilyFeedbackRows,
] = await Promise.all([
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
database.select().from(deviceAssets).where(eq(deviceAssets.organizationId, organizationId)),
database.select().from(maintenanceTickets).where(eq(maintenanceTickets.organizationId, organizationId)),
database.select().from(notices).where(eq(notices.organizationId, organizationId)),
database.select().from(alertRules).where(eq(alertRules.organizationId, organizationId)),
database.select().from(alertTriggers).where(eq(alertTriggers.organizationId, organizationId)),
database.select().from(familyContacts).where(eq(familyContacts.organizationId, organizationId)),
database.select().from(familyVisitAppointments).where(eq(familyVisitAppointments.organizationId, organizationId)),
database.select().from(familyFeedback).where(eq(familyFeedback.organizationId, organizationId)),
]);
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
const existingDeviceCodes = new Set(existingDeviceRows.map((device) => device.code));
const existingTicketTitles = new Set(existingTicketRows.map((ticket) => ticket.title));
const existingNoticeTitles = new Set(existingNoticeRows.map((notice) => notice.title));
const existingAlertRuleNames = new Set(existingAlertRuleRows.map((rule) => rule.name));
const existingAlertTriggerTitles = new Set(existingAlertTriggerRows.map((trigger) => trigger.title));
const existingFamilyContactKeys = new Set(existingFamilyContactRows.map((contact) => getFamilyContactKey(contact.elderId, contact.name)));
const existingFamilyVisitKeys = new Set(
existingFamilyVisitRows
.map((visit) => (visit.contactId ? getFamilyVisitKey(visit.elderId, visit.contactId, visit.notes) : ""))
.filter(Boolean),
);
const existingFamilyFeedbackKeys = new Set(
existingFamilyFeedbackRows
.map((feedback) => (feedback.contactId ? getFamilyFeedbackKey(feedback.elderId, feedback.contactId, feedback.content) : ""))
.filter(Boolean),
);
const getContactId = (contactRows: typeof existingFamilyContactRows, elderId: string, contactName: string): string | undefined =>
contactRows.find((contact) => contact.elderId === elderId && contact.name === contactName)?.id;
const getMissingFamilyVisits = (contactRows: typeof existingFamilyContactRows): SeedFamilyVisit[] =>
DEFAULT_FAMILY_VISITS.filter((visit) => {
const elderId = elderIdByName.get(visit.elderName);
const contactId = elderId ? getContactId(contactRows, elderId, visit.contactName) : undefined;
return Boolean(elderId && contactId && !existingFamilyVisitKeys.has(getFamilyVisitKey(elderId, contactId, visit.notes)));
});
const getMissingFamilyFeedback = (contactRows: typeof existingFamilyContactRows): SeedFamilyFeedback[] =>
DEFAULT_FAMILY_FEEDBACK.filter((feedback) => {
const elderId = elderIdByName.get(feedback.elderName);
const contactId = elderId ? getContactId(contactRows, elderId, feedback.contactName) : undefined;
return Boolean(elderId && contactId && !existingFamilyFeedbackKeys.has(getFamilyFeedbackKey(elderId, contactId, feedback.content)));
});
const missingDevices = DEFAULT_DEVICE_ASSETS.filter((device) => !existingDeviceCodes.has(device.code));
const missingTickets = DEFAULT_MAINTENANCE_TICKETS.filter((ticket) => !existingTicketTitles.has(ticket.title));
const missingNotices = DEFAULT_NOTICES.filter((notice) => !existingNoticeTitles.has(notice.title));
const missingAlertRules = DEFAULT_ALERT_RULES.filter((rule) => !existingAlertRuleNames.has(rule.name));
const missingAlertTriggers = DEFAULT_ALERT_TRIGGERS.filter((trigger) => !existingAlertTriggerTitles.has(trigger.title));
const missingFamilyContacts = DEFAULT_FAMILY_CONTACTS.filter((contact) => {
const elderId = elderIdByName.get(contact.elderName);
return Boolean(elderId && !existingFamilyContactKeys.has(getFamilyContactKey(elderId, contact.name)));
});
const missingFamilyVisits = getMissingFamilyVisits(existingFamilyContactRows);
const missingFamilyFeedback = getMissingFamilyFeedback(existingFamilyContactRows);
if (
missingDevices.length === 0 &&
missingTickets.length === 0 &&
missingNotices.length === 0 &&
missingAlertRules.length === 0 &&
missingAlertTriggers.length === 0 &&
missingFamilyContacts.length === 0 &&
missingFamilyVisits.length === 0 &&
missingFamilyFeedback.length === 0
) {
return;
}
const now = new Date();
await database.transaction(async (transaction) => {
const insertedDeviceRows = missingDevices.length > 0
? await transaction
.insert(deviceAssets)
.values(
missingDevices.map((device) => ({
organizationId,
name: device.name,
code: device.code,
category: device.category,
location: device.location,
status: device.status,
lastInspectedAt: device.lastInspectedHoursAgo === undefined ? undefined : new Date(now.getTime() - device.lastInspectedHoursAgo * 60 * 60 * 1000),
notes: device.notes,
})),
)
.returning()
: [];
if (insertedDeviceRows.length !== missingDevices.length) {
throw new Error("默认设备台账初始化失败");
}
const deviceIdByCode = new Map([...existingDeviceRows, ...insertedDeviceRows].map((device) => [device.code, device.id]));
if (missingTickets.length > 0) {
const ticketValues = missingTickets.map((ticket) => {
const deviceId = deviceIdByCode.get(ticket.deviceCode);
if (!deviceId) {
throw new Error("默认维修工单初始化失败");
}
return {
organizationId,
deviceId,
title: ticket.title,
description: ticket.description,
priority: ticket.priority,
status: ticket.status,
assigneeLabel: ticket.assigneeLabel,
resolutionNotes: ticket.resolutionNotes,
resolvedAt: ticket.status === "resolved" || ticket.status === "closed" ? now : undefined,
};
});
await transaction.insert(maintenanceTickets).values(ticketValues);
}
if (missingNotices.length > 0) {
await transaction.insert(notices).values(
missingNotices.map((notice) => ({
organizationId,
title: notice.title,
content: notice.content,
audience: notice.audience,
status: notice.status,
publishedAt: notice.publishedHoursAgo === undefined ? undefined : new Date(now.getTime() - notice.publishedHoursAgo * 60 * 60 * 1000),
})),
);
}
const insertedAlertRuleRows = missingAlertRules.length > 0
? await transaction.insert(alertRules).values(missingAlertRules.map((rule) => ({ ...rule, organizationId }))).returning()
: [];
if (insertedAlertRuleRows.length !== missingAlertRules.length) {
throw new Error("默认预警规则初始化失败");
}
const alertRuleIdByName = new Map([...existingAlertRuleRows, ...insertedAlertRuleRows].map((rule) => [rule.name, rule.id]));
const alertTriggerValues = missingAlertTriggers
.map((trigger) => {
const ruleId = alertRuleIdByName.get(trigger.ruleName);
const elderId = trigger.elderName ? elderIdByName.get(trigger.elderName) : undefined;
if (!ruleId || (trigger.elderName && !elderId)) {
return null;
}
return {
organizationId,
ruleId,
elderId,
title: trigger.title,
description: trigger.description,
status: trigger.status,
source: trigger.source,
handlingNotes: trigger.handlingNotes,
handledAt: trigger.status === "open" ? undefined : now,
createdAt: new Date(now.getTime() - trigger.createdHoursAgo * 60 * 60 * 1000),
updatedAt: now,
};
})
.filter((value): value is Exclude<typeof value, null> => value !== null);
if (alertTriggerValues.length > 0) {
await transaction.insert(alertTriggers).values(alertTriggerValues);
}
const insertedFamilyContactRows = missingFamilyContacts.length > 0
? await transaction
.insert(familyContacts)
.values(
missingFamilyContacts.map((contact) => {
const elderId = elderIdByName.get(contact.elderName);
if (!elderId) {
throw new Error("默认家属联系人初始化失败");
}
return {
organizationId,
elderId,
name: contact.name,
relationship: contact.relationship,
phone: contact.phone,
status: contact.status,
notes: contact.notes,
};
}),
)
.returning()
: [];
if (insertedFamilyContactRows.length !== missingFamilyContacts.length) {
throw new Error("默认家属联系人初始化失败");
}
const allFamilyContactRows = [...existingFamilyContactRows, ...insertedFamilyContactRows];
const familyVisitValues = getMissingFamilyVisits(allFamilyContactRows)
.map((visit) => {
const elderId = elderIdByName.get(visit.elderName);
const contactId = elderId ? getContactId(allFamilyContactRows, elderId, visit.contactName) : undefined;
if (!elderId || !contactId) {
return null;
}
return {
organizationId,
elderId,
contactId,
scheduledAt: new Date(now.getTime() + visit.scheduledHoursOffset * 60 * 60 * 1000),
status: visit.status,
notes: visit.notes,
handledAt: visit.status === "requested" ? undefined : now,
};
})
.filter((value): value is Exclude<typeof value, null> => value !== null);
if (familyVisitValues.length > 0) {
await transaction.insert(familyVisitAppointments).values(familyVisitValues);
}
const familyFeedbackValues = getMissingFamilyFeedback(allFamilyContactRows)
.map((feedback) => {
const elderId = elderIdByName.get(feedback.elderName);
const contactId = elderId ? getContactId(allFamilyContactRows, elderId, feedback.contactName) : undefined;
if (!elderId || !contactId) {
return null;
}
return {
organizationId,
elderId,
contactId,
feedbackType: feedback.feedbackType,
content: feedback.content,
status: feedback.status,
responseNotes: feedback.responseNotes,
handledAt: feedback.status === "open" ? undefined : now,
};
})
.filter((value): value is Exclude<typeof value, null> => value !== null);
if (familyFeedbackValues.length > 0) {
await transaction.insert(familyFeedback).values(familyFeedbackValues);
}
});
}
function hasRows(rows: unknown[]): boolean { function hasRows(rows: unknown[]): boolean {
return rows.length > 0; return rows.length > 0;
} }
@@ -1524,6 +1789,7 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
]); ]);
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) { if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
await seedDefaultCollaborationWorkspaceData(organizationId);
await seedDefaultAiWorkspaceData(organizationId); await seedDefaultAiWorkspaceData(organizationId);
return; return;
} }
@@ -1983,5 +2249,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
}), }),
); );
}); });
await seedDefaultCollaborationWorkspaceData(organizationId);
await seedDefaultAiWorkspaceData(organizationId); await seedDefaultAiWorkspaceData(organizationId);
} }

View File

@@ -1,23 +1,31 @@
import Link from "next/link"; import Link from "next/link";
import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, ListChecks, Users } from "lucide-react"; import { Activity, AlertTriangle, BedDouble, BrainCircuit, CheckCircle2, HeartPulse, ListChecks, Megaphone, ShieldAlert, TabletSmartphone, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table } from "@/components/ui/table"; import { Table } from "@/components/ui/table";
import type { ElderAiAnalysisBoardItem, ElderAiRiskLevel } from "@/modules/ai/types";
import type { AlertTriggerStatus } from "@/modules/alerts/types";
import { ALERT_TRIGGER_STATUS_LABELS } from "@/modules/alerts/types";
import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types"; import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types";
import type { CareTaskPriority, CareTaskStatus } from "@/modules/care/types"; import type { CareTaskPriority, CareTaskStatus } from "@/modules/care/types";
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS } from "@/modules/care/types"; import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS } from "@/modules/care/types";
import { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart"; import { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart";
import type { MaintenanceTicketPriority, MaintenanceTicketStatus } from "@/modules/devices/types";
import { MAINTENANCE_TICKET_PRIORITY_LABELS, MAINTENANCE_TICKET_STATUS_LABELS } from "@/modules/devices/types";
import { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types"; import { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types";
import type { FamilyFeedbackStatus, FamilyVisitStatus } from "@/modules/family/types";
import { FAMILY_FEEDBACK_STATUS_LABELS, FAMILY_VISIT_STATUS_LABELS } from "@/modules/family/types";
import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types"; import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types";
import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } from "@/modules/health/types"; import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } from "@/modules/health/types";
import type { NoticeStatus } from "@/modules/notices/types";
import { NOTICE_STATUS_LABELS } from "@/modules/notices/types";
export type DashboardMetric = { export type DashboardMetric = {
label: string; label: string;
value: string; value: string;
trend: string; trend: string;
icon: "admissions" | "beds" | "care" | "health" | "incidents" | "users"; icon: "admissions" | "ai" | "alerts" | "beds" | "care" | "devices" | "family" | "health" | "incidents" | "notices" | "users";
}; };
export type DashboardAdmission = { export type DashboardAdmission = {
@@ -56,26 +64,82 @@ export type DashboardHealthReview = {
title: string; title: string;
}; };
export type DashboardDeviceTicket = {
id: string;
deviceName: string;
priority: MaintenanceTicketPriority;
status: MaintenanceTicketStatus;
title: string;
updatedAt: string;
};
export type DashboardNotice = {
id: string;
audience: string;
status: NoticeStatus;
title: string;
updatedAt: string;
};
export type DashboardAlertTrigger = {
id: string;
elderName: string;
status: AlertTriggerStatus;
title: string;
updatedAt: string;
};
export type DashboardFamilyVisit = {
id: string;
contactName: string;
elderName: string;
scheduledAt: string;
status: FamilyVisitStatus;
};
export type DashboardFamilyFeedback = {
id: string;
elderName: string;
status: FamilyFeedbackStatus;
updatedAt: string;
};
type DashboardHomeProps = { type DashboardHomeProps = {
alertTriggers?: DashboardAlertTrigger[];
alertsHref: string;
aiBoardItems?: ElderAiAnalysisBoardItem[];
bedsHref: string; bedsHref: string;
careHref: string; careHref: string;
careTasks: DashboardCareTask[]; careTasks?: DashboardCareTask[];
deviceTickets?: DashboardDeviceTicket[];
devicesHref: string;
eldersHref: string;
emergencyHref: string; emergencyHref: string;
emergencyIncidents: DashboardIncident[]; emergencyIncidents?: DashboardIncident[];
familyFeedback?: DashboardFamilyFeedback[];
familyHref: string;
familyVisits?: DashboardFamilyVisit[];
healthHref: string; healthHref: string;
healthReviews: DashboardHealthReview[]; healthReviews?: DashboardHealthReview[];
metrics: DashboardMetric[]; metrics: DashboardMetric[];
occupancyData: BedOccupancyDatum[]; notices?: DashboardNotice[];
recentAdmissions: DashboardAdmission[]; noticesHref: string;
incidents: DashboardIncident[]; occupancyData?: BedOccupancyDatum[];
recentAdmissions?: DashboardAdmission[];
incidents?: DashboardIncident[];
}; };
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = { const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
admissions: CheckCircle2, admissions: CheckCircle2,
ai: BrainCircuit,
alerts: ShieldAlert,
beds: BedDouble, beds: BedDouble,
care: ListChecks, care: ListChecks,
devices: TabletSmartphone,
family: Users,
health: HeartPulse, health: HeartPulse,
incidents: HeartPulse, incidents: HeartPulse,
notices: Megaphone,
users: Users, users: Users,
}; };
@@ -107,19 +171,72 @@ function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" |
} }
} }
const analysisRiskLabels: Record<ElderAiRiskLevel, string> = {
critical: "高危",
high: "高风险",
low: "低风险",
medium: "中风险",
unknown: "未知",
};
function analysisRiskVariant(risk: ElderAiRiskLevel): "danger" | "secondary" | "success" | "warning" {
switch (risk) {
case "critical":
return "danger";
case "high":
case "medium":
return "warning";
case "low":
return "success";
case "unknown":
return "secondary";
}
}
export function DashboardHome({ export function DashboardHome({
alertTriggers,
alertsHref,
aiBoardItems,
bedsHref, bedsHref,
careHref, careHref,
careTasks, careTasks,
deviceTickets,
devicesHref,
eldersHref,
emergencyHref, emergencyHref,
emergencyIncidents, emergencyIncidents,
familyFeedback,
familyHref,
familyVisits,
healthHref, healthHref,
healthReviews, healthReviews,
metrics, metrics,
notices,
noticesHref,
occupancyData, occupancyData,
recentAdmissions, recentAdmissions,
incidents, incidents,
}: DashboardHomeProps): React.ReactElement { }: DashboardHomeProps): React.ReactElement {
const familyQueueItems: QueueItem[] = [
...(familyVisits ?? []).map((visit) => ({
id: `visit-${visit.id}`,
title: `${visit.elderName} 探访`,
detail: `${visit.contactName} / ${formatDateTime(visit.scheduledAt)}`,
badge: FAMILY_VISIT_STATUS_LABELS[visit.status],
variant: visit.status === "requested" ? "warning" as const : "secondary" as const,
meta: "探访",
})),
...(familyFeedback ?? []).map((feedback) => ({
id: `feedback-${feedback.id}`,
title: `${feedback.elderName} 家属反馈`,
detail: formatDateTime(feedback.updatedAt),
badge: FAMILY_FEEDBACK_STATUS_LABELS[feedback.status],
variant: feedback.status === "open" || feedback.status === "in_progress" ? "warning" as const : "secondary" as const,
meta: "反馈",
})),
].slice(0, 6);
const hasCoreQueues = careTasks !== undefined || healthReviews !== undefined || emergencyIncidents !== undefined;
const hasCollaborationQueues = deviceTickets !== undefined || notices !== undefined || alertTriggers !== undefined || familyVisits !== undefined || familyFeedback !== undefined;
return ( return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between"> <section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
@@ -148,7 +265,9 @@ export function DashboardHome({
})} })}
</section> </section>
{hasCoreQueues ? (
<section className="grid gap-4 xl:grid-cols-3"> <section className="grid gap-4 xl:grid-cols-3">
{careTasks !== undefined ? (
<QueueCard <QueueCard
emptyText="暂无待处理护理任务" emptyText="暂无待处理护理任务"
href={careHref} href={careHref}
@@ -162,6 +281,8 @@ export function DashboardHome({
}))} }))}
title="护理待办" title="护理待办"
/> />
) : null}
{healthReviews !== undefined ? (
<QueueCard <QueueCard
emptyText="暂无待复核健康异常" emptyText="暂无待复核健康异常"
href={healthHref} href={healthHref}
@@ -175,6 +296,8 @@ export function DashboardHome({
}))} }))}
title="健康复核" title="健康复核"
/> />
) : null}
{emergencyIncidents !== undefined ? (
<QueueCard <QueueCard
emptyText="暂无待处理应急事件" emptyText="暂无待处理应急事件"
href={emergencyHref} href={emergencyHref}
@@ -188,9 +311,71 @@ export function DashboardHome({
}))} }))}
title="安全应急" title="安全应急"
/> />
) : null}
</section> </section>
) : null}
{hasCollaborationQueues ? (
<section className="grid gap-4 xl:grid-cols-4">
{deviceTickets !== undefined ? (
<QueueCard
emptyText="暂无待处理维修工单"
href={devicesHref}
items={deviceTickets.map((ticket) => ({
id: ticket.id,
title: ticket.title,
detail: `${ticket.deviceName} / ${formatDateTime(ticket.updatedAt)}`,
badge: MAINTENANCE_TICKET_STATUS_LABELS[ticket.status],
variant: ticket.priority === "urgent" ? "danger" : ticket.priority === "high" ? "warning" : "secondary",
meta: MAINTENANCE_TICKET_PRIORITY_LABELS[ticket.priority],
}))}
title="设备工单"
/>
) : null}
{notices !== undefined ? (
<QueueCard
emptyText="暂无公告"
href={noticesHref}
items={notices.map((notice) => ({
id: notice.id,
title: notice.title,
detail: `${notice.audience} / ${formatDateTime(notice.updatedAt)}`,
badge: NOTICE_STATUS_LABELS[notice.status],
variant: notice.status === "published" ? "success" : "secondary",
meta: "公告",
}))}
title="公告通知"
/>
) : null}
{alertTriggers !== undefined ? (
<QueueCard
emptyText="暂无预警记录"
href={alertsHref}
items={alertTriggers.map((trigger) => ({
id: trigger.id,
title: trigger.title,
detail: `${trigger.elderName || "公共区域"} / ${formatDateTime(trigger.updatedAt)}`,
badge: ALERT_TRIGGER_STATUS_LABELS[trigger.status],
variant: trigger.status === "open" ? "danger" : trigger.status === "acknowledged" ? "warning" : "secondary",
meta: "预警",
}))}
title="规则预警"
/>
) : null}
{familyVisits !== undefined || familyFeedback !== undefined ? (
<QueueCard
emptyText="暂无家属协同事项"
href={familyHref}
items={familyQueueItems}
title="家属服务"
/>
) : null}
</section>
) : null}
{occupancyData !== undefined || recentAdmissions !== undefined ? (
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]"> <section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
{occupancyData !== undefined ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -207,7 +392,9 @@ export function DashboardHome({
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
) : null}
{recentAdmissions !== undefined ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
@@ -250,8 +437,52 @@ export function DashboardHome({
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) : null}
</section> </section>
) : null}
{aiBoardItems !== undefined ? (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<BrainCircuit className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription> AI </CardDescription>
</div>
<Link className="text-sm font-medium text-primary hover:underline" href={eldersHref}>
</Link>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{aiBoardItems.map((item) => (
<Link className="rounded-md border p-3 hover:bg-secondary/60" href={eldersHref} key={item.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.elderName}</p>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{item.restricted ? "结果受限:缺少一个或多个数据范围权限" : item.result?.summary ?? item.errorReason ?? "暂无摘要"}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{item.dataScopes.join(" / ")} / {formatDateTime(item.createdAt)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Badge variant={item.status === "failed" ? "danger" : "success"}>{item.status === "failed" ? "失败" : "已完成"}</Badge>
{!item.restricted && item.result ? (
<Badge variant={analysisRiskVariant(item.result.overallRiskLevel)}>{analysisRiskLabels[item.result.overallRiskLevel]}</Badge>
) : null}
</div>
</div>
</Link>
))}
{aiBoardItems.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
) : null}
{incidents !== undefined ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
@@ -274,6 +505,7 @@ export function DashboardHome({
{incidents.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null} {incidents.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent> </CardContent>
</Card> </Card>
) : null}
</div> </div>
); );
} }
@@ -284,7 +516,7 @@ type QueueItem = {
detail: string; detail: string;
meta: string; meta: string;
title: string; title: string;
variant: "danger" | "secondary" | "warning"; variant: "danger" | "secondary" | "success" | "warning";
}; };
function QueueCard({ function QueueCard({

View File

@@ -39,7 +39,7 @@ export const navGroups: NavGroup[] = [
path: "/dashboard", path: "/dashboard",
label: "运营看板", label: "运营看板",
icon: "dashboard", icon: "dashboard",
anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read"], anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read", "device:read", "notice:read", "alert:read", "family:read", "ai:read"],
}, },
{ {
path: "/elders", path: "/elders",