Compare commits

..

26 Commits

Author SHA1 Message Date
ee502a97ed chore: record journal 2026-07-27 19:07:52 -07:00
929beb7d84 chore(task): archive 07-27-sync-git-remotes 2026-07-27 19:07:46 -07:00
4efb8ea3b6 chore(task): record git remote sync 2026-07-27 19:07:16 -07:00
2682a509dc chore: record journal 2026-07-09 17:21:21 -07:00
b8d08cd461 chore(task): archive 07-09-ai-fee-frontend 2026-07-09 17:20:41 -07:00
ef4dd21d32 feat: add fee workspace and polish AI presentation 2026-07-09 17:05:19 -07:00
3aabdf0c4a fix: use prepared AI analysis outputs 2026-07-09 04:54:35 -07:00
933d06dbbb chore: record journal 2026-07-09 01:47:53 -07:00
7ac8d253cd chore(task): archive 07-08-complete-ops-ai-board 2026-07-09 01:45:31 -07:00
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
6a1add3422 fix: harden AI analysis and seed defaults 2026-07-08 04:38:22 -07:00
9a437f4cbf chore: record journal 2026-07-06 11:18:03 -07:00
ca7b0bb869 chore(task): archive 07-05-07-06-harden-ai-analysis-path 2026-07-06 11:17:33 -07:00
f74b7f3ca0 fix: remove pgvector dependency from AI knowledge retrieval 2026-07-06 10:36:21 -07:00
0d5093ac6c test: harden AI analysis path 2026-07-06 01:03:11 -07:00
ae561a7d45 fix: harden AI analysis provider responses 2026-07-05 03:40:17 -07:00
6ed7508983 fix: degrade elder analysis when knowledge retrieval fails 2026-07-05 02:33:26 -07:00
b586756226 chore: record journal 2026-07-05 00:51:19 -07:00
c3cf1d49cc chore(task): archive 07-04-ai-agent-knowledge-analysis 2026-07-05 00:50:24 -07:00
e204974b57 feat: add AI knowledge analysis MVP 2026-07-05 00:47:52 -07:00
6aa72d3b43 chore: record journal 2026-07-03 05:00:15 -07:00
0a77e87d55 chore(task): archive 07-03-invite-link-limits 2026-07-03 05:00:01 -07:00
9fd2088a2a feat: add invitation link limits 2026-07-03 04:59:34 -07:00
fb15d4db47 feat: add workspace theme toggle 2026-07-03 04:28:01 -07:00
c4d5222ddd chore: finish workspace cleanup 2026-07-03 04:04:54 -07:00
99 changed files with 19691 additions and 387 deletions

View File

@@ -223,6 +223,69 @@ async function classifyOrder(orderData: OrderData) {
| Invalid API key | Missing/wrong credentials | Check environment variables |
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
## Scenario: Elder AI prepared analysis surfaces
### 1. Scope / Trigger
- Trigger: backend elder AI analysis surfaces must return polished prepared analysis content without blocking on a live model provider.
- Apply this contract whenever changing `modules/ai/server/analysis.ts`, `modules/ai/components/ElderAiAnalysisDialog.tsx`, dashboard AI board rendering, or the elder analysis route.
### 2. Signatures
- History service: `listElderAiAnalyses(context, elderId) -> ServiceResult<{ history: ElderAiAnalysisHistoryItem[] }>`.
- Board service: `listAiAnalysisBoard(context, limit?) -> ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>`.
- Generation service: `generateElderAiAnalysis(context, elderId) -> ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>`.
- Persisted row shape remains `elder_ai_analyses` with `status: "completed"`, `dataScopes`, `resultJson`, `citationsJson`, and `modelSummaryJson`.
### 3. Contracts
- The elder analysis generation path must not require `AI_API_KEY`, `AI_BASE_URL`, or model runtime configuration to return a successful analysis.
- Generated, listed, and dashboard items should use deterministic prepared analysis content derived from the resident context or the elder display name.
- Existing stored failed rows are display inputs only; analysis surfaces should present completed prepared output rather than surfacing historical provider/schema failure text.
- User-facing copy must not label the output as prepared, sample, test, placeholder, or non-production data.
- Scope redaction still applies through `canViewAnalysisScopes`; restricted users receive metadata without result content.
### 4. Validation & Error Matrix
- Missing organization -> return `请选择机构后查看 AI 分析` or the generation-context equivalent with status `400`.
- Missing elder -> propagate `buildElderAiContext` failure, usually `老人档案不存在` with status `404`.
- Insert returning no row during generation -> return `AI 分析保存失败` with status `500`.
- Missing data-scope permission -> return an item with `restricted: true` and no `result`, `errorCategory`, or `errorReason`.
### 5. Good/Base/Bad Cases
- Good: history and dashboard show completed analysis cards with realistic summaries, findings, recommendations, data gaps, citations, and Chinese status labels.
- Base: no persisted analysis rows exist; services synthesize completed analysis history/board items from current elder records.
- Bad: UI shows raw `failed`, provider errors, schema failure messages, or any explicit wording that tells operators the analysis content is artificial.
### 6. Tests Required
- Unit: assert `generateElderAiAnalysis` builds resident context, inserts a completed row, records success audit, and never calls a chat provider.
- Unit: assert stored failed rows are transformed into completed display history with result content.
- Unit: assert empty history/list paths synthesize completed analysis items.
- Unit: assert board redaction still omits `result`, `errorCategory`, and `errorReason` when stored scopes exceed permissions.
### 7. Wrong vs Correct
#### Wrong
```typescript
return {
status: "failed",
errorReason: "AI 返回结构不符合固定分析格式",
};
```
#### Correct
```typescript
return {
status: "completed",
result: createPreparedAnalysisOutput({ elderId, elderName, citations, variantIndex }),
};
```
## 6. Prompt Engineering Best Practices
### Use XML Structure for Complex Prompts
@@ -349,3 +412,112 @@ GOOGLE_GENERATIVE_AI_API_KEY=...
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...
```
## Scenario: Elder AI Analysis MVP With LangChain And Keyword Knowledge Retrieval
### 1. Scope / Trigger
- Trigger: AI features that inspect resident, care, health, family, admission, alert, incident, or knowledge data.
- Current project contract: the elder analysis MVP uses LangChain on the server with an OpenAI-compatible chat provider, not the Vercel AI SDK runtime path.
- Keep provider calls under `modules/ai/server/*`; feature modules and API routes must not instantiate model clients directly.
- Knowledge retrieval is local keyword scoring over persisted chunks; it must not require an embedding provider or `pgvector`.
### 2. Signatures
- API:
- `GET /api/ai/elders/[id]/analyses`
- `POST /api/ai/elders/[id]/analyses`
- `GET /api/ai/knowledge`
- `POST /api/ai/knowledge`
- `PATCH /api/ai/knowledge/[id]`
- `DELETE /api/ai/knowledge/[id]`
- DB:
- `ai_knowledge_entries`
- `ai_knowledge_chunks` with text `content`; the legacy JSONB `embedding` column remains defaulted for compatibility and is not read or written by retrieval.
- `elder_ai_analyses`
- Runtime env:
- `AI_API_KEY` required for chat generation only.
- `AI_BASE_URL` optional OpenAI-compatible endpoint.
- `AI_CHAT_MODEL` optional, defaults in `modules/ai/server/config.ts`.
- Do not introduce `AI_EMBEDDING_MODEL`; knowledge retrieval must work without embedding config.
### 3. Contracts
- All API responses use the project API shape: `{ success: boolean, reason: string, ...payload }`.
- Knowledge entry input fields:
- `scope`: `"platform" | "organization"`
- `title`: non-empty string
- `category`: string
- `tags`: string
- `body`: non-empty string
- `status`: `"enabled" | "disabled"`
- Elder analysis history stores:
- `status`: `"completed" | "failed"`
- `dataScopes`: JSON array of included families
- `resultJson`: only for completed rows
- `errorCategory` / `errorReason`: sanitized only for failed rows
- Failed rows must not store prompts, full resident context snapshots, API keys, or raw provider errors.
- Knowledge retrieval may return enabled platform knowledge and enabled active-organization knowledge only.
- Knowledge retrieval first filters enabled platform / active-organization chunks in SQL, then scores chunks with local lexical overlap in `modules/ai/server/knowledge.ts`; this keeps the MVP deployable on plain PostgreSQL without the `vector` extension or embedding API calls.
- Completed elder analysis `resultJson.modelSummary` must include `{ provider: "openai-compatible", chatModel, knowledgeRetrieval: "keyword" }`.
- Completed elder analysis `resultJson.citations` must be derived from citation IDs actually referenced by `keyFindings[].citationIds` and `recommendations[].citationIds`; do not append unused available citations.
### 4. Validation & Error Matrix
- Missing `ai:read` -> `403 权限不足`.
- Missing `elder:read` -> `403 权限不足`.
- Missing active organization for elder analysis -> `400`.
- Target elder outside active organization -> `404`.
- Missing `knowledge:read` -> knowledge scope is omitted from analysis context.
- Missing `knowledge:manage` -> knowledge mutation routes return `403`.
- Platform knowledge mutation without `platform:manage` -> `403`.
- Missing `AI_API_KEY` -> save failed analysis with `missing_config`, return structured failure.
- Provider failure -> save failed analysis with `provider_error` or `timeout`.
- Invalid model output -> save failed analysis with `schema_validation_failed`.
- Unknown citation IDs in findings or recommendations -> save failed analysis with `schema_validation_failed`, return structured failure.
### 5. Good/Base/Bad Cases
- Good: org manager generates elder analysis; context includes only data families allowed by the manager's current permissions; history stores matching `dataScopes`.
- Base: caregiver generates analysis; caregiver gets `ai:read` and `knowledge:read` by default, but cannot mutate knowledge.
- Bad: viewer has `elder:read` but no `ai:read`; the AI row action must not be exposed and API must return `403`.
- Bad: organization user attempts to retrieve another organization's private knowledge; the retrieval query must not include those chunks.
### 6. Tests Required
- Permission seeding assertions for `ai:*` and `knowledge:*` default role grants.
- Knowledge retrieval tests for platform shared, own organization, other organization, disabled entry filtering, keyword-score ordering, nonmatching chunk exclusion, and no embedding-provider calls.
- Analysis tests for missing config, schema validation failure, failed-history sanitization, and data-scope redaction.
- Analysis tests for unknown citation rejection and referenced-only citation persistence.
- Route tests for auth/permission failures and standard response shape.
### 7. Wrong vs Correct
#### Wrong
```typescript
// Do not call a model from a feature route and pass raw resident rows directly.
const model = new ChatOpenAI({ apiKey: process.env.AI_API_KEY });
await model.invoke(JSON.stringify(residentRows));
```
#### Correct
```typescript
// Keep model calls in modules/ai/server and persist only citations that findings/recommendations reference.
const result = await generateElderAiAnalysis(auth.context, elderId);
if (!result.success) {
return jsonFailure(result.reason, result.status);
}
return jsonSuccess("AI 分析已生成", { analysis: result.data.analysis }, 201);
```
```typescript
// Good evidence chain: every cited source ID is from the allowed resident/knowledge citations.
const finding = {
category: "跌倒风险",
severity: "warning",
evidence: "夜间徘徊后需要加强通道清理和巡视。",
citationIds: ["resident-1", "kb-1"],
};
```

View File

@@ -111,6 +111,107 @@ await database.transaction(async (transaction) => {
});
```
## Scenario: Organization Invitation Limits
### 1. Scope / Trigger
- Trigger: changing organization invitation creation, registration-by-invite consumption, or the `organization_invitations` table.
- Applies because invitation links are persisted in PostgreSQL, created through a protected organization API, consumed inside account registration, and displayed in organization settings.
### 2. Signatures
- `POST /api/organizations/[id]/invitations`
- Request: `{ email?: string; roleId: string; validityDays?: number; maxUses?: number }`
- Response: `ApiResult<{ invitation: typeof organizationInvitations.$inferSelect }>`
- DB columns:
- `organization_invitations.expires_at timestamp with time zone not null`
- `organization_invitations.max_uses integer not null default 1`
- `organization_invitations.used_count integer not null default 0`
- Registration helper: `createRegistration({ name, email, password, organizationId?, invitationToken? })`
### 3. Contracts
- Invite creation requires `account:manage` and may only target the active organization when the session is organization-scoped.
- `roleId` must refer to an enabled role in the target organization.
- If invitation `email` is non-empty, registration must use the same normalized email address.
- `validityDays` defaults to `7` and must be a positive integer within the product limit.
- `maxUses` defaults to `1` and must be a positive integer within the product limit.
- "Use count" means successful invitation registrations, not page visits or token preview requests.
- A token is consumable only when `status = 'active'`, `expires_at >= now`, and `used_count < max_uses`.
- Successful registration inserts the account and membership in the same transaction before consuming the invitation.
- Consuming an invitation increments `used_count`; only when the returned count reaches `max_uses` should the row become `status = 'accepted'` and receive `accepted_by_account_id` / `accepted_at`.
### 4. Validation & Error Matrix
- Invalid JSON body -> `400` / `请求数据格式无效`.
- Missing `roleId` -> `400` / `请选择邀请角色`.
- Invalid `validityDays` -> `400` / `邀请有效期需为 ... 的整数`.
- Invalid `maxUses` -> `400` / `最大使用次数需为 ... 的整数`.
- Target organization missing -> `404` / `机构不存在`.
- Role missing, disabled, or cross-organization -> `404` / `角色不存在`.
- Unknown invitation token during registration -> `邀请链接无效`.
- Expired, non-active, or exhausted invitation token during registration -> `邀请链接已失效`.
- Registration email mismatch for an email-limited invitation -> `邀请邮箱与注册邮箱不一致`.
### 5. Good/Base/Bad Cases
- Good: keep invitation limit constants shared between the create API and invite dialog so UI bounds and backend validation stay aligned.
- Good: consume invitation links with an atomic `UPDATE ... WHERE used_count < max_uses` predicate and check that a row was returned.
- Good: derive invitation list UI from the persisted `used_count / max_uses` fields.
- Base: old one-use behavior remains the default by using `validityDays = 7` and `maxUses = 1`.
- Bad: count page loads as invitation usage; link previews, refreshes, and bots can exhaust a link without a registration.
- Bad: decide whether a link is exhausted only from a value read before registration; concurrent registrations can over-consume the link.
### 6. Tests Required
- `pnpm db:generate`, then review SQL for only additive invitation limit columns or intentional invitation changes.
- `pnpm lint`
- `pnpm type-check`
- `pnpm test`
- `pnpm build`
- Integration assertions when route-level tests cover auth registration:
- default invite creation returns a 7-day, one-use link
- custom `validityDays` and `maxUses` are persisted
- expired and exhausted links fail registration
- successful registration increments `used_count`
- the final allowed registration marks the invitation accepted
### 7. Wrong vs Correct
#### Wrong
```ts
if (invitation.usedCount >= invitation.maxUses) {
throw new Error("邀请链接已失效");
}
await transaction.update(organizationInvitations)
.set({ usedCount: invitation.usedCount + 1 })
.where(eq(organizationInvitations.id, invitation.id));
```
#### Correct
```ts
const consumedRows = await transaction
.update(organizationInvitations)
.set({ usedCount: sql`${organizationInvitations.usedCount} + 1` })
.where(and(
eq(organizationInvitations.id, invitation.id),
eq(organizationInvitations.status, "active"),
gte(organizationInvitations.expiresAt, now),
lt(organizationInvitations.usedCount, organizationInvitations.maxUses),
))
.returning({
id: organizationInvitations.id,
maxUses: organizationInvitations.maxUses,
usedCount: organizationInvitations.usedCount,
});
const consumed = consumedRows[0];
if (!consumed) {
throw new Error("邀请链接已失效");
}
```
## Scenario: Facility Room Creation Without Fabricated Hierarchy
### 1. Scope / Trigger
@@ -377,7 +478,7 @@ await database
- Good: use one additive `care_tasks` table for MVP execution records instead of building a full recurring care-plan engine.
- Good: preserve `/app/health` separation; care execution is operational and not the health admin settings page.
- Base: `elderId` can be nullable for future public-area checks, but seeded MVP examples should use real elders.
- Bad: render fake care metrics in `ModulePage` after the module becomes real.
- Bad: render fake care metrics in a static placeholder after the module becomes real.
- Bad: update task status by ID alone without `organizationId`.
- Bad: only disable buttons in the UI while leaving Route Handlers on broad elder permissions.

View File

@@ -244,6 +244,79 @@ if (type === "tool-output-available") {
}
```
## Scenario: Elder AI Analysis Panel MVP
### 1. Scope / Trigger
- Trigger: UI surfaces that render elder AI analysis, knowledge retrieval results, or AI history.
- Current project contract: the MVP elder workflow is a fixed structured analysis panel, not chat and not streaming.
- Do not use `@ai-sdk/react` hooks for this MVP panel; use the existing project API result shape through fetch until a generated client exists for these routes.
### 2. Signatures
- Elder row action:
- Visible only when current permissions include both `ai:read` and `elder:read`.
- Opens `ElderAiAnalysisDialog` with `width="wide"`.
- Client APIs:
- `GET /api/ai/elders/[id]/analyses` -> `{ success, reason, history }`
- `POST /api/ai/elders/[id]/analyses` -> `{ success, reason, analysis }`
- `GET /api/ai/knowledge` -> `{ success, reason, entries }`
- Knowledge mutations -> `{ success, reason, entry }`
### 3. Contracts
- Render completed analysis from fixed fields:
- `overallRiskLevel`
- `summary`
- `keyFindings[]`
- `recommendations[]`
- `dataGaps[]`
- `citations[]`
- `confidence`
- `modelSummary`
- Render `restricted: true` history items as placeholders only; do not assume `result` or citations exist.
- Render failed history from sanitized `errorCategory` / `errorReason`; never display raw provider errors.
- The MVP UI must not provide one-click business mutations or confirmable drafts from recommendations.
### 4. Validation & Error Matrix
- Missing `ai:read` or `elder:read` -> hide row action.
- API failure -> show `reason` in the dialog or settings page message area.
- `restricted: true` history -> show an access-restricted placeholder.
- `status === "failed"` -> show failed badge and sanitized reason.
- Empty history -> show empty state.
- Generation pending -> disable generate button and show loading label.
### 5. Good/Base/Bad Cases
- Good: authorized manager opens dialog, generates analysis, sees latest completed result, citations, data gaps, and history list.
- Base: generation fails because provider is not configured; user sees a structured failure message and failed history remains visible.
- Bad: viewer without `ai:read` sees an `AI 分析` row action.
- Bad: frontend renders recommendations as buttons that create care tasks, alert handling notes, or health review records.
### 6. Tests Required
- Component or route smoke coverage for hidden AI action when permissions are missing.
- History rendering coverage for completed, failed, restricted, and empty states.
- Knowledge settings coverage for read-only users and `knowledge:manage` users.
- Build check must include both unscoped `/app/settings/knowledge` and scoped `/app/[organizationSlug]/settings/knowledge`.
### 7. Wrong vs Correct
#### Wrong
```tsx
// Do not expose an AI action based only on elder read permission.
{permissions.includes("elder:read") ? <button>AI </button> : null}
```
#### Correct
```tsx
const canUseAi = permissions.includes("ai:read") && permissions.includes("elder:read");
{canUseAi ? <Button onClick={() => setAiTarget(elder)}>AI </Button> : null}
```
## 7. Best Practices Summary
| Rule | Description |

View File

@@ -165,44 +165,17 @@ The project Dialog adapter must:
### Static Module Data Contract
Static or reserved module pages must not fabricate operational data. If a module does
not have a Drizzle-backed query/API and real persisted records, render a clear empty or
not-connected state instead of generated counters, fake workflows, sample records, or
mock priority/status rows.
Operational module pages must not fabricate data. If a module does not have a
Drizzle-backed query/API and real persisted records, render a clear empty or
not-connected state inside that module's own route/component instead of generated
counters, fake workflows, sample records, or mock priority/status rows.
`modules/shared/components/ModulePage.tsx` is the shared placeholder for these modules.
Its props should stay descriptive only:
The old shared reserved-module placeholder components were removed after devices,
notices, alerts, family, health, care, and emergency became real persisted workspaces.
Do not reintroduce a generic `ReservedModulePages` layer for operational modules.
```typescript
type ModulePageProps = {
title: string;
eyebrow: string;
description: string;
icon: LucideIcon;
phase: "待接入" | "二期预留" | "三期预留";
};
```
```typescript
// Bad: static pages pretending to have live business data.
<ModulePage
title="Care"
metrics={[{ label: "Today", value: "184", detail: "142 done" }]}
workflows={["Create plan", "Dispatch task"]}
/>
// Good: no fabricated records until a real data source exists.
<ModulePage
title="Care"
eyebrow="Plans and inspections"
description="Pending connection to care plans, task dispatch, and execution records."
icon={ClipboardCheck}
phase="待接入"
/>
```
When a module becomes real, replace the placeholder with a Server Component that loads
data from the server boundary and pass only persisted, permission-filtered data into
When a module becomes real, implement the route as a Server Component that loads data
from the server boundary and passes only persisted, permission-filtered data into
client components.
### App Shell Tenant and Account Menu Contract

View File

@@ -21,9 +21,9 @@ the rest conversationally.
## Status (update the checkboxes as you complete each item)
- [ ] Fill backend guidelines
- [ ] Fill frontend guidelines
- [ ] Add code examples
- [x] Fill backend guidelines
- [x] Fill frontend guidelines
- [x] Add code examples
---

View File

@@ -3,7 +3,7 @@
"name": "00-bootstrap-guidelines",
"title": "Bootstrap Guidelines",
"description": "Fill in project development guidelines for AI agents",
"status": "in_progress",
"status": "completed",
"dev_type": "docs",
"scope": null,
"package": null,
@@ -11,7 +11,7 @@
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-01",
"completedAt": null,
"completedAt": "2026-07-03",
"branch": null,
"base_branch": null,
"worktree_path": null,

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,82 @@
# Invite Link Limits Design
## Architecture
This change spans the persisted invitation model, the invitation create API, the registration consumption transaction, and the organization settings UI.
The invitation row remains the source of truth. The existing `expires_at` column continues to represent validity. New persisted counters will represent usage limits:
- `max_uses`: maximum successful invitation registrations allowed.
- `used_count`: number of successful registrations that have consumed the invitation.
## Data Model
Add non-null integer columns to `organization_invitations`:
- `max_uses integer not null default 1`
- `used_count integer not null default 0`
Existing invitation rows remain compatible: previous one-time invites become `max_uses = 1`, `used_count = 0` unless already accepted. The existing `status = accepted` already prevents use of accepted legacy rows.
## Invitation Creation Contract
`POST /api/organizations/[id]/invitations` accepts:
- `email?: string`
- `roleId: string`
- `validityDays?: number`
- `maxUses?: number`
Defaults:
- `validityDays = 7`
- `maxUses = 1`
Validation:
- `roleId` is still required.
- `validityDays` must be a positive integer in an operationally reasonable range.
- `maxUses` must be a positive integer in an operationally reasonable range.
- Invalid input returns `jsonFailure(...)` with the existing structured API shape.
## Invitation Consumption
Registration only counts a use after account creation and membership insertion succeed inside the existing transaction.
An invitation is valid only if:
- `status === "active"`
- `expiresAt >= now`
- `usedCount < maxUses`
On successful invitation registration:
- Increment `usedCount`.
- If the increment reaches `maxUses`, set `status = "accepted"` and fill `acceptedByAccountId` / `acceptedAt` with the consuming account details.
- If remaining uses exist, keep `status = "active"` and leave accepted metadata empty.
The update should guard against concurrent over-consumption by updating with a `used_count < max_uses` predicate and checking that a row was returned.
## UI
`OrganizationInviteDialog` adds controls for:
- Validity days, default 7.
- Maximum uses, default 1.
`OrganizationDetailClient` displays:
- Expiry time.
- Usage as `usedCount / maxUses`.
Copy behavior remains unchanged.
## Compatibility
The existing `/register?invite=...` URL format remains unchanged.
Existing rows without the new columns are migrated through default values. Existing one-time semantics are preserved by default.
## Rollback
If needed, UI inputs can be hidden and backend defaults can keep the old behavior. Dropping the columns would require a reverse migration only if deployed migrations must be rolled back.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,42 @@
# Invite Link Limits Implementation Plan
## Checklist
1. Update Trellis planning artifacts and get approval to start implementation.
2. Load pre-development specs with `trellis-before-dev`.
3. Update `modules/core/server/schema.ts`:
- Add `maxUses` and `usedCount` integer columns to `organizationInvitations`.
4. Generate or add the Drizzle migration for the new columns.
5. Update shared invitation types and row mappers:
- `modules/core/types.ts`
- `modules/core/server/settings.ts`
- `modules/core/server/store.ts`
6. Update invitation creation API:
- Parse `validityDays` and `maxUses`.
- Validate positive integer ranges.
- Persist `expiresAt`, `maxUses`, and `usedCount`.
7. Update invitation registration flow:
- Reject exhausted links.
- Increment usage inside the transaction.
- Mark status accepted only when `usedCount` reaches `maxUses`.
- Guard concurrent consumption with an update predicate.
8. Update frontend:
- Add validity and maximum-use controls to `OrganizationInviteDialog`.
- Display usage count in `OrganizationDetailClient`.
- Update invitation explanatory text where it still says invitations are one-time only.
9. Run validation:
- `pnpm lint`
- `pnpm type-check`
- `pnpm test` if tests are relevant or fast enough after type changes.
- `pnpm build` if lint/type-check pass.
## Risk Points
- Concurrent registrations could over-consume an invite unless the update predicate checks `used_count < max_uses`.
- Existing accepted invitations should remain unusable regardless of migrated counter defaults.
- Frontend numeric inputs submit strings, so backend parsing must not trust the client.
## Rollback Points
- Before migration: schema/API/UI changes can be reverted together.
- After migration: reverting code while keeping columns is safe because defaults preserve one-use invite behavior.

View File

@@ -0,0 +1,48 @@
# Invite link limits
## Goal
Allow organization admins to configure invitation link validity and usage limits when creating invite links.
This should make invite links safer to distribute by letting admins choose how long a link remains valid and how many times it may be used.
## Confirmed Facts
- Invitation links are created from `modules/settings/components/OrganizationInviteDialog.tsx`.
- The invitation create API is `app/api/organizations/[id]/invitations/route.ts`.
- Invitation persistence is backed by the `organization_invitations` table in `modules/core/server/schema.ts`.
- Invitations already have an `expiresAt` column and are currently created with a fixed 7-day validity window.
- Registration consumes invitation tokens through `modules/core/server/auth.ts`.
- The current registration flow marks an invitation as `accepted` after one successful invitation registration, so existing invite links behave as single-use links.
- Invitation rows are shown in `modules/settings/components/OrganizationDetailClient.tsx`, currently with target, role, status, expiry, and copy action.
## Requirements
- Admins can configure an invitation link's validity period when creating an organization invitation.
- Admins can configure the maximum usage count for an invitation link when creating an organization invitation.
- Existing behavior remains compatible by default: if the admin does not change the controls, a new invite should expire after 7 days and be usable once.
- The backend validates create-invitation inputs and rejects invalid limit values with structured API failures.
- The registration path rejects expired or exhausted invitation links.
- Invitation list data exposes enough information to show configured limits and current usage.
- The UI makes the configured validity and usage limit visible when creating and reviewing invitations.
- Maximum access count means maximum successful invitation registrations, not page loads or token preview requests.
## Out of Scope
- Invitation revocation management beyond existing status behavior.
- Editing limits after an invitation has been created.
- Tracking anonymous page views unless product intent explicitly requires page-load based access counting.
## Acceptance Criteria
- [x] Creating an invitation with default form values produces a 7-day, one-use invitation.
- [x] Creating an invitation with a custom validity period stores and displays the corresponding expiry.
- [x] Creating an invitation with a custom maximum usage count stores and displays the configured usage limit.
- [x] Registration with an expired invitation fails with the existing invalid/expired invitation behavior.
- [x] Registration after the invitation has reached its maximum allowed uses fails.
- [x] Successful invitation registration increments usage accounting and only exhausts the link when the configured maximum is reached.
- [x] Type-check, lint, and relevant tests/build checks pass.
## Notes
- Recommended interpretation: count successful invitation registrations as usage. Counting page loads would make links vulnerable to browser refreshes, previews, bots, and accidental visits, and it would require adding a separate token validation/view endpoint.

View File

@@ -0,0 +1,26 @@
{
"id": "invite-link-limits",
"name": "invite-link-limits",
"title": "Invite link limits",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-03",
"completedAt": "2026-07-03",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,395 @@
# AI Agent Knowledge Analysis Design
## Overview
Implement the MVP as a server-side LangChain AI module with PostgreSQL/pgvector-backed knowledge retrieval and a structured elder AI analysis panel in the existing elder list workflow.
The MVP is not a chat assistant. It is a deterministic product workflow:
1. Authorized user opens an elder's AI analysis dialog from the elder list.
2. Server loads only the resident data families the user can read.
3. Server retrieves enabled platform shared knowledge and enabled active-organization knowledge.
4. LangChain generates a fixed structured analysis.
5. Server saves completed or failed history, writes audit logs, and returns structured JSON.
6. UI renders latest analysis, history, citations, recommendations, and restricted placeholders where permissions do not allow detail access.
## Module Boundaries
### New Module
Create `modules/ai/`:
- `modules/ai/types.ts`
- Zod-compatible validation helpers or TypeScript runtime validators for knowledge entries, analysis output, history status, risk levels, citation shapes, and data scopes.
- `modules/ai/server/config.ts`
- Reads `AI_BASE_URL`, `AI_API_KEY`, `AI_CHAT_MODEL`, `AI_EMBEDDING_MODEL`, and optional timeout/limit settings.
- `modules/ai/server/knowledge.ts`
- CRUD for knowledge entries.
- Chunking and embedding on save.
- Scope-filtered retrieval from pgvector.
- `modules/ai/server/elder-context.ts`
- Builds full resident graph with permission-aware optional data families.
- `modules/ai/server/analysis.ts`
- Runs LangChain analysis flow.
- Validates structured output.
- Saves completed/failed history.
- `modules/ai/components/KnowledgeManagementClient.tsx`
- Settings UI for manual knowledge entries.
- `modules/ai/components/ElderAiAnalysisDialog.tsx`
- Elder row dialog panel for generation, latest analysis, history, and citations.
### Existing Integration Points
- `modules/core/server/schema.ts`
- Add AI enums/tables.
- `modules/core/types.ts`
- Add permissions and exported AI-related shared types as needed.
- `modules/core/server/permissions.ts`
- Seed AI/knowledge permission definitions and default role grants.
- `app/api/ai/...`
- New API routes for knowledge and elder analysis.
- `app/(app)/app/settings/knowledge/page.tsx`
- New settings route.
- `app/(app)/app/[organizationSlug]/settings/knowledge/page.tsx`
- Scoped route re-export.
- `modules/shared/lib/navigation.ts`
- Add settings navigation item visible with `knowledge:read`.
- `modules/elders/components/EldersClient.tsx`
- Add per-row AI analysis action and dialog.
## Dependencies
Add runtime dependencies:
- `@langchain/openai`
- `@langchain/core`
- `langchain`
Add pgvector support through Drizzle/Postgres:
- Use PostgreSQL `vector` extension.
- Prefer a pgvector-capable Postgres image for local development instead of plain `postgres:17-alpine`.
- If Drizzle vector column support is insufficient for the exact version, use custom SQL migration for vector extension/index while keeping typed table metadata in schema.
Vercel AI SDK remains out of the MVP runtime path.
## Data Model
### Permissions
Add permission IDs:
- `ai:read`
- `ai:manage`
- `knowledge:read`
- `knowledge:manage`
Default grants:
- `platform_admin`: all four.
- `org_admin`: all four.
- `manager`: all four.
- `caregiver`: `ai:read`, `knowledge:read`.
- `viewer`, `family`, `resident`: none.
### Knowledge Entries
Table: `ai_knowledge_entries`
Fields:
- `id uuid primary key`
- `scope enum('platform', 'organization')`
- `organizationId uuid null references organizations(id) on delete cascade`
- `title text not null`
- `category text not null default ''`
- `tags text not null default ''`
- `body text not null`
- `status enum('enabled', 'disabled') not null default 'enabled'`
- `createdByAccountId uuid null references accounts(id)`
- `updatedByAccountId uuid null references accounts(id)`
- `createdAt timestamptz not null default now()`
- `updatedAt timestamptz not null default now()`
Rules:
- `scope='platform'` requires `organizationId IS NULL`.
- `scope='organization'` requires `organizationId` to match the active organization.
- Platform entries require platform-level authorization to manage.
- Organization entries require active organization plus `knowledge:manage`.
### Knowledge Chunks
Table: `ai_knowledge_chunks`
Fields:
- `id uuid primary key`
- `entryId uuid not null references ai_knowledge_entries(id) on delete cascade`
- `organizationId uuid null`
- `scope enum('platform', 'organization')`
- `chunkIndex integer not null`
- `content text not null`
- `embedding vector(<embedding_dimensions>) not null`
- `sourceTitle text not null`
- `sourceCategory text not null default ''`
- `createdAt timestamptz not null default now()`
Indexes:
- B-tree on `(scope, organization_id)`.
- Vector index for embedding similarity, using pgvector operator/index supported by deployment.
Embedding dimensions are tied to `AI_EMBEDDING_MODEL`; document the chosen default in env configuration.
### Elder AI Analyses
Table: `elder_ai_analyses`
Fields:
- `id uuid primary key`
- `organizationId uuid not null references organizations(id) on delete cascade`
- `elderId uuid not null references elders(id) on delete cascade`
- `actorAccountId uuid null references accounts(id)`
- `status enum('completed', 'failed') not null`
- `dataScopes text not null`
- `resultJson jsonb null`
- `citationsJson jsonb not null default '[]'::jsonb`
- `modelSummaryJson jsonb not null default '{}'::jsonb`
- `errorCategory text not null default ''`
- `errorReason text not null default ''`
- `createdAt timestamptz not null default now()`
Rules:
- Completed rows require a valid `resultJson`.
- Failed rows must not store prompt, full resident context, or raw provider errors.
- `dataScopes` stores a stable comma-separated list or JSON array; use JSONB if Drizzle ergonomics are acceptable.
## Authorization Model
### Generate Analysis
Required:
- Authenticated session.
- Active organization.
- `ai:read`.
- `elder:read`.
- Target elder belongs to active organization.
Optional data families:
- `health` only if `health:read`.
- `care` only if `care:read`.
- `family` only if `family:read`.
- `admission` only if `admission:read`.
- `facility` only if `facility:read`.
- `alert` only if `alert:read`.
- `incident` only if `incident:read`.
- `knowledge` only if `knowledge:read`.
`dataScopes` records exactly which families were included.
### View Analysis History
History list can show safe metadata for rows in the active organization and elder when the user has `ai:read` and `elder:read`.
History detail requires all permissions implied by `dataScopes`. If missing any scope, return or render a restricted placeholder and do not include `resultJson`/citations content.
### Knowledge Retrieval
Retrieval requires `knowledge:read`.
Eligible entries:
- `scope='platform'`, `status='enabled'`.
- `scope='organization'`, `organizationId = activeOrganizationId`, `status='enabled'`.
No organization-private chunks from other organizations may be retrievable.
## LangChain Flow
### Provider
Use OpenAI-compatible configuration:
- `AI_BASE_URL`
- `AI_API_KEY`
- `AI_CHAT_MODEL`
- `AI_EMBEDDING_MODEL`
`config.ts` validates required settings before generation/embedding and returns structured failures for missing configuration.
### Retrieval
1. Convert resident query/context summary into embedding.
2. Query `ai_knowledge_chunks` with pgvector similarity.
3. Filter by allowed scopes before vector ranking.
4. Return top K chunks with source metadata:
- source type: `knowledge`
- entry id
- chunk id
- title
- category
- scope
### Resident Context Assembly
Build one typed object:
- Elder basics.
- Current bed/admission context.
- Admission history.
- Health profile.
- Recent vitals.
- Chronic conditions.
- Health anomaly reviews.
- Care tasks.
- Alert triggers/rules where relevant.
- System incidents matched by elder/bed where relevant.
- Family contacts/visits/feedback.
- Retrieved knowledge snippets.
Use batch queries and `Promise.all` where independent. No `await` in loops.
### Structured Output
Output schema:
- `overallRiskLevel`: `low | medium | high | critical | unknown`
- `summary`: string
- `keyFindings`: array of:
- `category`: string
- `severity`: `info | warning | critical`
- `evidence`: string
- `citationIds`: string[]
- `recommendations`: array of:
- `priority`: `low | normal | high | urgent`
- `action`: string
- `reason`: string
- `citationIds`: string[]
- `dataGaps`: string[]
- `citations`: array of:
- `id`: string
- `sourceType`: `record | knowledge`
- `sourceId`: string
- `title`: string
- `excerpt`: string
- `confidence`: number from 0 to 1
- `modelSummary`: object with provider base URL host, chat model, embedding model, and generation timestamp, excluding API key.
Use LangChain structured output where possible. Validate again server-side before saving.
## API Design
### Elder Analysis
- `GET /api/ai/elders/[id]/analyses`
- Returns latest allowed analysis metadata/detail and history list.
- Redacts detail for records whose `dataScopes` exceed current permissions.
- `POST /api/ai/elders/[id]/analyses`
- Synchronously generates a new analysis.
- Returns `{ success: true, reason, analysis }` on completion.
- On failure, saves failed history and audit log, then returns `{ success: false, reason }`.
### Knowledge
- `GET /api/ai/knowledge`
- Requires `knowledge:read`.
- Lists platform and active organization entries visible to caller.
- `POST /api/ai/knowledge`
- Requires `knowledge:manage`.
- Creates entry, chunks, embeds, writes audit log.
- `PATCH /api/ai/knowledge/[id]`
- Requires `knowledge:manage`.
- Updates entry and rebuilds chunks/embeddings.
- `DELETE /api/ai/knowledge/[id]`
- Requires `knowledge:manage`.
- Deletes entry and cascading chunks.
All routes return the existing API response shape with `Cache-Control: no-store`.
## UI Design
### Elder List
In `EldersClient`:
- Add AI analysis button per row for users with `ai:read`.
- Use lucide icon such as `Sparkles`.
- Open `Dialog` with `width="wide"`.
- Dialog sections:
- Resident summary.
- Generate button with loading state.
- Latest analysis summary.
- Findings and recommendations.
- Data gaps.
- Citations.
- History list with restricted placeholders.
No chat input and no one-click business creation.
### Knowledge Settings Page
Route: `/settings/knowledge`
Features:
- List entries by scope/status/category/search.
- Create/edit dialog with title, body, category, tags, scope, enabled/disabled.
- Disable mutations when missing `knowledge:manage`.
- Show embedding/rebuild status through response messages; no background jobs.
## Audit And Logging
Audit actions:
- `ai.elderAnalysis.generate`
- `ai.elderAnalysis.generateFailed`
- `ai.knowledge.create`
- `ai.knowledge.update`
- `ai.knowledge.delete`
- `ai.knowledge.disable`
- `ai.knowledge.enable`
Audit records include actor, organization, target type/id, result, and sanitized reason.
Do not store:
- Full prompts.
- Full resident context snapshots.
- Raw provider errors.
- API keys.
## Failure Handling
Generation failures are classified:
- `missing_configuration`
- `provider_error`
- `timeout`
- `schema_validation_failed`
- `retrieval_failed`
- `unknown`
On failure:
1. Save failed analysis history with sanitized category/reason.
2. Write audit log.
3. Return structured API failure.
4. UI shows a concise error state and keeps prior completed history visible.
## Rollout And Compatibility
- Requires database migration for pgvector extension, AI enums, knowledge tables, chunk table, analysis table, and permissions.
- Local `compose.yaml` should use a pgvector-capable PostgreSQL image or document manual extension installation.
- Production deployment must verify `CREATE EXTENSION vector` support before migration.
- If AI env vars are missing, knowledge CRUD can still work except embedding-dependent save should fail gracefully; analysis generation should return a structured configuration failure.
## Open Trade-Offs
- Embedding dimensions depend on the selected embedding model. The implementation should define a default and document migration impact if changed.
- Synchronous generation is acceptable for MVP but can be replaced later by async jobs using the same history table status field.
- Platform shared knowledge increases utility but requires careful scope filters in every retrieval query.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,305 @@
# AI Agent Knowledge Analysis Implementation Plan
## Preconditions
- User reviews and approves `prd.md`, `design.md`, and `implement.md`.
- Task status is moved from `planning` to `in_progress` with `task.py start`.
- Before editing code in Phase 2, load `trellis-before-dev` and relevant specs:
- `.trellis/spec/shared/code-quality.md`
- `.trellis/spec/shared/typescript.md`
- `.trellis/spec/shared/dependencies.md`
- `.trellis/spec/backend/directory-structure.md`
- `.trellis/spec/backend/database.md`
- `.trellis/spec/backend/authentication.md`
- `.trellis/spec/backend/type-safety.md`
- `.trellis/spec/backend/quality.md`
- `.trellis/spec/frontend/components.md`
- `.trellis/spec/frontend/type-safety.md`
- `.trellis/spec/frontend/quality.md`
## Implementation Checklist
### 1. Dependencies And Environment
- Add LangChain runtime packages:
- `langchain`
- `@langchain/core`
- `@langchain/openai`
- Decide and document default embedding dimension for `AI_EMBEDDING_MODEL`.
- Add env handling in `modules/ai/server/config.ts`:
- `AI_BASE_URL`
- `AI_API_KEY`
- `AI_CHAT_MODEL`
- `AI_EMBEDDING_MODEL`
- Update local PostgreSQL image or setup notes so pgvector is available.
Validation:
- `pnpm install`
- `pnpm type-check`
Rollback:
- Remove added dependencies and AI env usage if the AI module is backed out.
### 2. Database Schema And Migration
- Update `modules/core/server/schema.ts`:
- AI enums for knowledge scope/status and analysis status.
- `aiKnowledgeEntries`.
- `aiKnowledgeChunks`.
- `elderAiAnalyses`.
- Add pgvector extension migration support.
- Generate Drizzle migration with `pnpm db:generate`.
- Manually inspect generated SQL for:
- `CREATE EXTENSION IF NOT EXISTS vector`.
- Correct foreign keys.
- Correct indexes.
- Correct vector column type/dimension.
Validation:
- `pnpm db:generate`
- `pnpm type-check`
- Apply migration locally when DB is available: `pnpm db:migrate`.
Rollback:
- Drop AI tables and extension-dependent indexes in a rollback migration if needed.
- Do not drop `vector` extension automatically if other future objects may use it.
### 3. Permissions And Role Seeding
- Update `modules/core/types.ts`:
- Add `ai:read`, `ai:manage`, `knowledge:read`, `knowledge:manage`.
- Update `modules/core/server/permissions.ts`:
- Add permission definitions.
- Update default role grants:
- `platform_admin`: all four.
- `org_admin`: all four.
- `manager`: all four.
- `caregiver`: `ai:read`, `knowledge:read`.
- `viewer`, `family`, `resident`: none.
- Ensure `ensureSystemDefaults()` upserts permissions and role grants without deleting custom grants.
Validation:
- `pnpm type-check`
- Targeted tests or assertions for seeded permission definitions and role grants.
Rollback:
- Remove permissions from default grants only through an intentional migration/seed change; avoid deleting custom user role state.
### 4. AI Domain Types
- Create `modules/ai/types.ts`.
- Define:
- Knowledge entry input and status/scope values.
- Analysis status values.
- Data scope values.
- Risk/severity/priority values.
- Structured analysis output type and runtime validator.
- Citation type.
- Sanitized error category type.
- Avoid `any`; use explicit `unknown` validation helpers when parsing API bodies or AI output.
Validation:
- Unit tests for validators if they contain non-trivial parsing.
- `pnpm type-check`
Rollback:
- Types are isolated; remove module if feature is backed out.
### 5. Knowledge Service
- Create `modules/ai/server/knowledge.ts`.
- Implement:
- List visible entries by permission and active organization.
- Create/update/delete entries with scope validation.
- Server-side chunking.
- Embedding generation through LangChain OpenAI-compatible embeddings.
- Chunk rebuild on create/update.
- Scope-filtered retrieval for platform shared + active organization entries.
- Write audit logs for create/update/delete/enable/disable.
- Ensure no organization-private chunks from other organizations can be retrieved.
Validation:
- Tests for scope filtering:
- Platform shared visible to organization user.
- Own organization visible.
- Other organization not visible.
- Disabled entries not retrieved.
- Tests for mutation permission failures.
Rollback:
- Disable `/settings/knowledge` navigation and API routes if embedding is unavailable.
- Existing tables can remain unused.
### 6. Elder Context Service
- Create `modules/ai/server/elder-context.ts`.
- Implement permission-aware resident graph assembly:
- Always include elder basics after `elder:read`.
- Include health only with `health:read`.
- Include care only with `care:read`.
- Include family only with `family:read`.
- Include admission/facility only with matching read permissions.
- Include alert/incident only with matching read permissions.
- Include knowledge only with `knowledge:read`.
- Return `dataScopes` exactly matching included families.
- Use batch queries and `Promise.all` for independent loads.
Validation:
- Tests for data family inclusion/exclusion by permissions.
- Tests that target elder must belong to active organization.
Rollback:
- This service is isolated under `modules/ai`; remove API use if feature is disabled.
### 7. Analysis Service
- Create `modules/ai/server/analysis.ts`.
- Implement:
- Generate structured analysis synchronously with LangChain.
- Validate AI output against fixed schema.
- Save `completed` history with result/citations/model summary/data scopes.
- On failure, save `failed` history with sanitized error category/reason.
- Write success/failure audit logs.
- Redact history details when current permissions do not satisfy stored `dataScopes`.
- Do not store full prompts, full context snapshots, API keys, or raw provider errors.
Validation:
- Tests for:
- Missing config -> structured failure + failed history.
- Schema validation failure -> failed history.
- History detail redaction when permissions are insufficient.
- Completed history shape.
Rollback:
- Keep history rows as audit evidence; hide UI/API if provider issues block rollout.
### 8. API Routes
- Add elder analysis routes:
- `app/api/ai/elders/[id]/analyses/route.ts`
- `GET`: latest/history with permission-aware redaction.
- `POST`: synchronous generation.
- Add knowledge routes:
- `app/api/ai/knowledge/route.ts`
- `GET`, `POST`.
- `app/api/ai/knowledge/[id]/route.ts`
- `PATCH`, `DELETE`.
- Use existing `requirePermission`, `jsonSuccess`, `jsonFailure`, `readJsonBody`, and `recordAuditLog` patterns.
- Preserve `Cache-Control: no-store` through existing helpers.
Validation:
- Route tests for auth/permission failures and structured responses.
- `pnpm type-check`
Rollback:
- Remove routes or return feature-disabled failures while keeping data.
### 9. Knowledge Settings UI
- Create:
- `app/(app)/app/settings/knowledge/page.tsx`
- `app/(app)/app/[organizationSlug]/settings/knowledge/page.tsx`
- `modules/ai/components/KnowledgeManagementClient.tsx`
- Update:
- `modules/shared/lib/navigation.ts`
- `modules/shared/components/AppSidebarNav.tsx` icon map if a new icon key is needed.
- UI supports:
- List/filter/search.
- Create/edit dialog.
- Enable/disable/delete.
- Read-only view if missing `knowledge:manage`.
- Follow existing settings/client component patterns.
Validation:
- Manual browser check.
- Type-check/lint.
Rollback:
- Remove nav entry and route.
### 10. Elder Analysis UI
- Update `modules/elders/components/EldersClient.tsx`.
- Add `AI 分析` row action for authorized users.
- Create `modules/ai/components/ElderAiAnalysisDialog.tsx`.
- Dialog displays:
- Elder summary.
- Generate button/loading state.
- Latest completed analysis.
- Failed state if latest attempt failed.
- Findings, recommendations, data gaps.
- Citations.
- History list with restricted placeholders.
- No chat input.
- No one-click business mutation.
Validation:
- Manual browser check for desktop/mobile widths.
- Confirm button text fits and table layout remains stable.
- Verify unauthorized users do not see the action.
Rollback:
- Remove row action; API/history remains dormant.
### 11. Quality Gate
Run:
- `pnpm lint`
- `pnpm type-check`
- `pnpm test` if route/domain tests were added.
- `pnpm build` before final completion if time and environment allow.
Manual checks:
- Knowledge CRUD as authorized admin/manager.
- Knowledge read-only as caregiver.
- Elder analysis generation with configured AI provider.
- Missing AI config failure.
- History redaction across roles.
- Other-organization knowledge cannot appear in retrieval.
## Risky Files
- `modules/core/server/schema.ts`
- `modules/core/types.ts`
- `modules/core/server/permissions.ts`
- `compose.yaml`
- `drizzle/*`
- `modules/elders/components/EldersClient.tsx`
- `modules/shared/lib/navigation.ts`
## Rollback Points
- After dependency install: revert package changes if LangChain package compatibility fails.
- After schema migration generation: inspect SQL before applying.
- After permissions update: verify default role grants before touching UI.
- After API routes: keep UI disabled until permission and scope tests pass.
- After UI: remove nav/row entry if backend generation is not production-ready.
## Follow-Up Checks Before Start
- Confirm pgvector deployment support on the target PostgreSQL server.
- Pick and document embedding dimensions for the configured default embedding model.
- Confirm production AI provider and env var names.
- Decide whether to add seed knowledge entries for smoke testing.

View File

@@ -0,0 +1,158 @@
# AI agent knowledge analysis
## Goal
Add an AI capability layer for TeaTea Pension that can use agent-style orchestration, retrieve organization-scoped knowledge, and provide AI analysis in relevant care-operation workflows.
The MVP workflow is elder profile AI analysis: given one resident, the system should gather authorized, organization-scoped resident context, retrieve relevant knowledge, and produce a cited, structured operational analysis for staff review.
The MVP interaction is a fixed "generate AI analysis" panel, not a conversational assistant. The panel should produce structured output such as risk summary, key evidence, recommended actions, source citations, and confidence.
The MVP entry point should be an "AI 分析" action in each elder table row. Clicking it opens a `wide` dialog panel that shows elder context, a generate action, the latest analysis, analysis history, and citations.
The MVP resident context should use the full available resident graph: elder basics, current bed/admission context, admission history, health profile, recent vitals, chronic conditions, health anomaly reviews, care tasks, alerts/incidents, family feedback, and relevant knowledge-base snippets.
MVP knowledge-base content should be manually maintained as structured knowledge entries with title, body, category/tags, scope, organization scope when applicable, and enabled status. On save, the server should chunk and embed the content into pgvector-backed storage. File upload, OCR, web crawling, and external-system sync are out of scope for MVP ingestion.
MVP retrieval should support platform shared knowledge plus organization-private knowledge. Ordinary organization users can retrieve enabled platform shared entries and enabled entries for their active organization. Platform knowledge is maintained by platform-level authorized users; organization knowledge is maintained by organization-level authorized users.
MVP must include a `/settings/knowledge` management page under the existing settings area. The sidebar entry should live in the "管理系统" group, be visible to `knowledge:read`, and allow create/edit/enable/disable/delete only with `knowledge:manage`.
MVP elder analyses should be persisted as history records with organization, elder, actor, structured result, source citations, model configuration summary, status, and creation time. Persisting history supports auditability, comparison over time, and token-cost control.
MVP AI analysis output must be a fixed structured schema rendered by the frontend rather than free-form text. The schema should include `overallRiskLevel`, `summary`, `keyFindings[]`, `recommendations[]`, `dataGaps[]`, `citations[]`, `confidence`, and `modelSummary`. Findings should include category, severity, evidence, and citation references. Recommendations should remain advisory and must not create business drafts.
MVP analysis generation should run synchronously in the request. The UI should show a loading state while generation is in progress. Analysis history should support at least `completed` and `failed` statuses so failures are visible and auditable without requiring an async job system.
On generation failure, the system should save a `failed` analysis history record and write an audit log. Failed history records must store only sanitized error category and brief reason, such as `provider_error`, `timeout`, or `schema_validation_failed`; they must not store full prompts, resident context, sensitive data, or raw provider errors.
Each analysis history record must store `dataScopes` describing the data families used to generate it, such as `elder`, `health`, `care`, `family`, `admission`, `alert`, `incident`, and `knowledge`. Viewing a history detail requires `ai:read`, `elder:read`, active organization access, and all read permissions implied by that record's `dataScopes`; otherwise the UI should show a restricted placeholder rather than the analysis content.
The broader planning objective still covers:
- LangChain + agent module.
- Knowledge-base retrieval.
- AI analysis from multiple places in the product.
The feature should improve operational decision support without weakening tenant isolation, role permissions, auditability, or existing deterministic workflows.
## Confirmed Facts
- The application is a single-repo Next.js 15 / React 19 / TypeScript app with API routes, Drizzle ORM, PostgreSQL, and TailwindCSS. See `package.json`.
- Runtime dependencies currently do not include `ai`, `@ai-sdk/*`, `langchain`, vector database clients, or embedding providers. See `package.json`.
- Local infrastructure currently runs a single `postgres:17-alpine` service, and Drizzle migrations are generated from `modules/core/server/schema.ts`. There is no existing pgvector extension or external vector database configuration. See `compose.yaml` and `drizzle.config.ts`.
- Project specs already contain Vercel AI SDK guidance for backend and frontend AI features in `.trellis/spec/backend/ai-sdk-integration.md` and `.trellis/spec/frontend/ai-sdk-integration.md`.
- The actual backend code uses `modules/core/server/*` for database, auth, audit, permissions, and schema; it is not using the package layout shown in some generic specs.
- Authentication uses a custom `teatea_session` cookie and Drizzle-backed `sessions`, `accounts`, `organizations`, `memberships`, `roles`, and permissions. See `modules/core/server/auth.ts` and `.trellis/spec/backend/authentication.md`.
- Most operational tables are tenant-scoped by `organizationId`, and resident-specific data often includes `elderId`. See `modules/core/server/schema.ts`.
- Current permission families include care, health, device, notice, alert, family, facility, admission, elder, incident, audit, role, account, organization, and platform permissions. See `modules/core/types.ts` and `modules/core/server/permissions.ts`.
- The dashboard already aggregates elders, beds, admissions, care tasks, health reviews, and emergency incidents with permission checks. See `app/(app)/app/dashboard/page.tsx`.
- Health currently has deterministic anomaly detection for vital records and creates health anomaly reviews from thresholds. See `modules/health/server/operations.ts`.
- Alerting already has rule and trigger tables that can represent warnings and suggested handling. See `modules/alerts/server/operations.ts` and `modules/core/server/schema.ts`.
- Existing elder/bed operational context can already attach permission-filtered care task, health review/vital, and incident lines to elders. See `modules/operations/server/context.ts`.
- Health profile, vital, chronic condition, anomaly review, family contact, visit, feedback, and admission data exist but would need an AI-specific resident context assembler to gather one resident's full analysis input. See `modules/health/server/operations.ts`, `modules/family/server/operations.ts`, and `modules/core/server/operations.ts`.
- API responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`. See `modules/core/server/api.ts`.
## Requirements
- The AI module must preserve organization isolation: every retrieval, prompt context, generated analysis, and generated action draft must be scoped to the authenticated active organization unless the caller has a platform-level reason to cross scopes.
- The AI module must preserve existing role permissions: analysis endpoints must require the same read permissions as the underlying data they inspect, and any write/action-producing workflow must require the matching manage permission.
- MVP authorization must add dedicated AI and knowledge permissions while still applying underlying domain data permissions.
- Dedicated MVP permission points should include `ai:read`, `ai:manage`, `knowledge:read`, and `knowledge:manage`.
- Generating elder AI analysis must require `ai:read`, `elder:read`, an active organization, and only include optional resident-context data when the caller has the matching read permission such as `health:read`, `care:read`, `alert:read`, `family:read`, `admission:read`, `facility:read`, or `incident:read`.
- Analysis history details must be filtered by stored `dataScopes` so users cannot view generated content derived from data families they cannot currently read.
- Maintaining knowledge entries must require `knowledge:manage`; using retrieved knowledge in analysis must require `knowledge:read`.
- Default role seeding should grant:
- `platform_admin`: `ai:read`, `ai:manage`, `knowledge:read`, `knowledge:manage`.
- `org_admin`: `ai:read`, `ai:manage`, `knowledge:read`, `knowledge:manage`.
- `manager`: `ai:read`, `ai:manage`, `knowledge:read`, `knowledge:manage`.
- `caregiver`: `ai:read`, `knowledge:read`.
- `viewer`, `family`, and `resident`: no AI or knowledge permissions by default.
- The first implementation should add a shared server-side AI boundary rather than scattering provider calls across feature modules.
- The AI boundary must allow future provider/model changes without touching each product surface.
- MVP AI orchestration must use LangChain as the primary server-side runtime for model calls, retrieval chains, agent/tool orchestration, and structured analysis output.
- Vercel AI SDK should not be part of the MVP runtime path; keep it as a future option for conversational or streaming UI work.
- MVP model access must use an OpenAI-compatible configurable provider interface with environment variables for base URL, API key, chat model, and embedding model.
- The MVP UI must be a structured analysis panel attached to the elder profile/list workflow rather than a free-form chat assistant.
- The MVP elder UI entry point must be an "AI 分析" action in each elder table row that opens a `wide` dialog panel.
- MVP knowledge management UI must be available at `/settings/knowledge` with read access gated by `knowledge:read` and mutations gated by `knowledge:manage`.
- The MVP analysis input must use the full available resident graph when the caller has matching read permissions.
- MVP LangChain agent/tools must be read-only. They may load resident context, retrieve knowledge, and read analysis history, but must not provide mutation tools.
- Knowledge retrieval must return source metadata that the UI can display or store with the analysis, so AI outputs are traceable to underlying records or documents.
- MVP knowledge retrieval must store chunks and embeddings in PostgreSQL with pgvector, reusing the existing Drizzle/Postgres persistence boundary.
- MVP knowledge ingestion must support manually maintained knowledge entries and server-side chunking/embedding on save.
- MVP knowledge retrieval must include enabled platform shared knowledge plus enabled active-organization private knowledge, filtered by caller permissions.
- AI analysis must be advisory by default. MVP output is limited to summaries, risk notes, recommendations, data gaps, and citations; future action-draft workflows require separate requirements and explicit user confirmation.
- MVP elder AI analysis must only display structured recommendations. It must not create confirmable business drafts or one-click mutations for care tasks, alert triggers, health review notes, or other operational records.
- MVP elder AI analyses must be saved as history records after generation.
- MVP generation should run synchronously and save completed or failed history status.
- Failed generation attempts must persist sanitized failed history and write audit logs.
- Generated outputs must use structured schemas where the product needs machine-readable data, such as severity, confidence, citations, suggested next steps, or draft entity payloads.
- MVP elder analysis output must use a fixed JSON-compatible schema with risk level, summary, findings, recommendations, data gaps, citations, confidence, and model summary.
- AI calls must have observable failure handling: user-facing failures should be structured, and server logs/audit records should identify operation type, actor, organization, target, and result.
## Candidate Product Surfaces
- MVP: Elder profile analysis: summarize one resident's health notes, vitals, care tasks, alerts, admissions, and family feedback.
- Dashboard executive summary: summarize current risk across care, health, beds, admissions, and incidents.
- Health anomaly explanation: explain why a review matters and suggest follow-up questions or actions.
- Alert and incident triage: summarize context, recommend severity/status, and draft handling notes.
- Care operations assistant: answer operational questions from scoped data and draft care-task notes.
- Knowledge-base Q&A: retrieve internal policies, care protocols, notices, facility SOPs, and relevant system records.
## Constraints
- Do not bypass deterministic rules already present in health anomaly detection and alert handling; AI should augment those flows first.
- Do not introduce unscoped vector search that can leak data across organizations.
- Do not introduce a separate vector database/service for the MVP.
- Do not let AI-specific permissions bypass the underlying domain permissions for resident, health, care, family, alert, admission, facility, device, or incident data.
- Do not show analysis history content to users who lack read permission for any stored `dataScopes` used by the analysis.
- Do not expose organization-private knowledge to other organizations through platform shared retrieval.
- Do not include file upload, document parsing, OCR, crawling, or external knowledge sync in the MVP.
- Do not make knowledge maintenance API-only; MVP needs a user-facing settings page.
- Do not store full prompts or sensitive resident data in third-party logs unless explicitly configured and reviewed.
- Do not store full prompts, resident context, sensitive data, or raw provider errors in failed history records.
- Do not hard-code a single model vendor into feature modules.
- Do not treat generated AI analysis history as an authoritative clinical record; it is advisory operational support.
- Do not add "one-click create" or confirmable draft business mutations from MVP AI analysis output.
- Do not define mutation-capable LangChain tools in the MVP, even if they are not exposed in the UI.
- Do not require frontend components to know provider-specific APIs.
- Do not start implementation until `design.md` and `implement.md` exist and the user has approved the final planning artifacts.
## Acceptance Criteria
- [ ] PRD identifies elder profile AI analysis as the MVP user workflow and fixed structured analysis panel as the MVP interaction.
- [ ] Elder list rows expose an AI analysis action for authorized users and open a wide analysis dialog.
- [ ] PRD defines the full resident graph as MVP data sources and identifies out-of-scope AI surfaces.
- [ ] Technical design defines the AI server module boundary, provider/orchestration choice, RAG storage strategy, authorization model, source-citation contract, audit/logging contract, and fallback behavior.
- [ ] MVP implementation uses LangChain as the server-side AI orchestration layer and does not introduce Vercel AI SDK runtime code.
- [ ] MVP model configuration supports OpenAI-compatible `AI_BASE_URL`, `AI_API_KEY`, `AI_CHAT_MODEL`, and `AI_EMBEDDING_MODEL` style settings.
- [ ] Implementation plan defines ordered steps, validation commands, risky files, migration needs, and rollback points.
- [ ] Any implemented AI analysis endpoint checks authentication, active organization, and required permissions before loading data or retrieving knowledge.
- [ ] MVP adds and seeds `ai:read`, `ai:manage`, `knowledge:read`, and `knowledge:manage` permission definitions and assigns them to appropriate default operational/admin roles.
- [ ] Default role permission seeding matches the agreed AI/knowledge grants for platform admins, org admins, managers, caregivers, viewers, family users, and residents.
- [ ] Elder analysis context only includes each optional data family when the caller has the matching domain read permission.
- [ ] Analysis history records store `dataScopes`, and history detail visibility is denied or redacted when the current user lacks any required scope permission.
- [ ] Any implemented retrieval mechanism stores and filters knowledge by organization scope and returns source metadata with each retrieval result.
- [ ] MVP retrieval uses PostgreSQL + pgvector with Drizzle migrations and organization-scoped query filters.
- [ ] MVP knowledge entries can be manually maintained and embedded into searchable chunks with source metadata.
- [ ] `/settings/knowledge` exists for authorized users and supports list, filter, create, edit, enable/disable, and delete workflows.
- [ ] Knowledge retrieval includes only enabled platform shared knowledge and enabled knowledge for the caller's active organization.
- [ ] MVP AI analysis output contains recommendations only and does not create or persist business drafts/actions beyond the analysis history record itself.
- [ ] MVP LangChain tools are read-only and cannot mutate care, health, alert, family, admission, facility, incident, or knowledge data.
- [ ] MVP AI analysis responses and persisted history use the fixed structured output schema.
- [ ] Generated elder analyses are persisted with actor, elder, organization, structured result, citation metadata, model summary, status, and timestamps.
- [ ] Synchronous generation shows a loading state and returns structured API success/failure responses.
- [ ] Failed generation attempts persist sanitized failed history records and audit logs without full prompts or raw sensitive context.
- [ ] Quality verification includes `pnpm lint`, `pnpm type-check`, and targeted tests for permission scoping and structured failure behavior.
## Likely Out of Scope For MVP
- Fully autonomous agents that mutate production records without a user confirmation step.
- Cross-organization analytics for ordinary organization users.
- Replacing existing deterministic anomaly rules with AI-only decisions.
- Conversational AI/chat UX for MVP elder analysis.
- Real-time streaming chat across the whole product.
- Async generation jobs, background workers, polling, and retry queues.
- Building a full document-management system before the minimum knowledge-ingestion path is defined.
- PDF/Word upload, OCR, web crawling, and external-system knowledge synchronization.

View File

@@ -0,0 +1,26 @@
{
"id": "ai-agent-knowledge-analysis",
"name": "ai-agent-knowledge-analysis",
"title": "AI agent knowledge analysis",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-04",
"completedAt": "2026-07-05",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,74 @@
# Harden AI Analysis Path Design
## Overview
This change is an in-place hardening pass over the existing AI MVP. It keeps the current module boundaries and synchronous workflow, then adds tests and narrows citation behavior.
## Boundaries
### Backend AI module
- `modules/ai/types.ts`
- Keep runtime validators as the source of truth for output shape.
- Add/adjust helpers only if needed for citation ID validation.
- `modules/ai/server/analysis.ts`
- Validate model output against the allowed citation set before persisting a completed history row.
- Derive final persisted/display citations from citation IDs actually referenced by findings/recommendations.
- Keep all provider and validation failures sanitized.
- `modules/ai/server/knowledge.ts`
- Preserve current save-time embedding and scope-filtered retrieval behavior.
- Add tests around existing behavior rather than introducing background embedding state.
### API routes
- `app/api/ai/elders/[id]/analyses/route.ts`
- `app/api/ai/knowledge/route.ts`
- `app/api/ai/knowledge/[id]/route.ts`
Route behavior stays unchanged except tests protect permission ordering, structured failures, and audit behavior.
### Frontend
- `modules/ai/components/KnowledgeManagementClient.tsx`
- Make AI configuration / vector generation failures more explicit.
- `modules/ai/components/ElderAiAnalysisDialog.tsx`
- Make failed latest history state clearer without exposing raw errors.
### Permissions
- `modules/core/server/permissions.ts`
- Clarify `ai:manage` as reserved/currently governance-oriented wording only.
## Citation Contract
Allowed citations are built by `buildElderAiContext` plus knowledge retrieval. The model may reference only these IDs.
Final persisted `result.citations` must be derived from actual references:
1. Collect citation IDs from all `keyFindings[].citationIds` and `recommendations[].citationIds`.
2. If any referenced ID is absent from the allowed citation map, reject the output as `schema_validation_failed`.
3. Persist/display only the allowed citations whose IDs were referenced.
4. Preserve stable ordering from the original allowed citation list.
This avoids showing unused evidence and prevents hallucinated source IDs.
## Test Strategy
Tests should mock database/provider boundaries and assert observable contracts, not implementation details.
- Validator tests use pure functions.
- Knowledge tests mock `getDatabase` and `OpenAIEmbeddings` or use light fake query builders where practical.
- Analysis tests mock config, elder context, knowledge retrieval, model invocation, database insert, and audit logging.
- Route tests mock `requirePermission`, AI services, validation as needed, and audit logging.
## Compatibility
No new dependency is required.
Deployment hardening additionally changes the still-unapplied `0007` AI migration from pgvector to JSONB embeddings, so production PostgreSQL 18 Alpine can run the migration without a `vector` extension.
Existing persisted `resultJson.citations` remain readable; new analyses will have stricter citation lists.
## Rollback
- Revert citation strictness if a provider cannot satisfy citation references, but keep tests documenting expected loosened behavior.
- Revert UI message changes independently if copy causes product concern.
- Tests are additive and can stay even if implementation is adjusted.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,44 @@
# Harden AI Analysis Path Implementation Plan
## Steps
1. Add pure validator tests for `modules/ai/types.ts`.
2. Add/adjust analysis citation helper behavior in `modules/ai/server/analysis.ts`.
3. Add analysis service tests for missing config, invalid output, provider errors, retrieval degradation, and redaction.
4. Add knowledge service tests for scope, disabled entries, organization isolation, and embedding failure.
5. Add AI route tests for permission failures, invalid input, service failure propagation, success responses, and audit calls.
6. Improve knowledge UI and analysis dialog failure copy.
7. Clarify `ai:manage` permission description.
8. Align AI knowledge embedding storage to JSONB for production PostgreSQL without pgvector.
9. Run targeted AI tests, then project verification.
## Validation Commands
- `pnpm test -- modules/ai/types.test.ts`
- `pnpm test -- modules/ai/server/analysis.test.ts modules/ai/server/knowledge.test.ts app/api/ai/ai-routes.test.ts`
- `pnpm lint`
- `pnpm type-check`
- `pnpm test`
- `pnpm build`
## Risky Files
- `modules/ai/server/analysis.ts`
- `modules/ai/server/knowledge.ts`
- `modules/ai/types.ts`
- `app/api/ai/*/route.ts`
- `modules/core/server/permissions.ts`
## Review Gates
- Citation helper must reject unknown citation IDs and avoid appending unused citations.
- Tests must not call real AI providers.
- Tests must not require a live PostgreSQL instance unless explicitly scoped to migration smoke testing.
- No new `any`, non-null assertions, `@ts-ignore`, or `@ts-expect-error`.
- Failed history records must remain sanitized.
## Rollback Points
- After citation strictness: revert helper and related tests if provider output compatibility proves too brittle.
- After route tests: routes should remain behavior-compatible except status/copy assertions.
- After UI copy: copy changes can be reverted without affecting backend contracts.

View File

@@ -0,0 +1,49 @@
# Harden AI Analysis Path PRD
## Goal
Harden the existing elder AI analysis and knowledge retrieval MVP so the critical safety boundaries are covered by tests and the displayed evidence chain matches the citations actually used by the model output.
## Background
The current MVP now implements LangChain-backed elder AI analysis, PostgreSQL JSONB-backed knowledge retrieval, permission-scoped resident context, persisted analysis history, and knowledge management UI.
Current evidence from repository inspection:
- AI service files live under `modules/ai/`.
- API routes live under `app/api/ai/`.
- AI tables and JSONB embedding storage are in `modules/core/server/schema.ts` and `drizzle/0007_purple_puff_adder.sql`; the production target does not require PostgreSQL `vector` extension support.
- Existing project verification passes: `pnpm lint`, `pnpm type-check`, and `pnpm test`.
- No AI-specific test file currently covers schema validation, permission scoping, history redaction, knowledge organization isolation, disabled knowledge exclusion, or provider failure behavior.
- `normalizeCitations` currently appends allowed citations that the model did not necessarily cite, weakening the evidence chain.
## Requirements
1. Add focused automated tests for AI validators, knowledge service behavior, analysis service behavior, and AI route authorization/response behavior.
2. Tighten citation handling so the persisted/rendered `citations` list is derived from citation IDs actually referenced by findings and recommendations.
3. Reject AI outputs whose finding or recommendation `citationIds` include IDs not present in the allowed context/knowledge citation set.
4. Keep failed analysis history sanitized: no full prompt, resident context, API key, or raw provider error.
5. Preserve the existing synchronous generation MVP behavior; do not introduce queues, streaming, background jobs, new dependencies, or a new vector store.
6. Improve user-facing failure text where configuration/vector generation failure would otherwise look like a generic save/generation failure.
7. Clarify `ai:manage` as a reserved/future governance permission instead of implying a currently implemented management surface.
## Acceptance Criteria
- `validateKnowledgeEntryInput`, `validateElderAiAnalysisOutput`, and `parseDataScopes` have direct unit coverage for valid and invalid edge cases.
- Knowledge tests cover writable scope failures, disabled entry exclusion, platform/current-organization visibility, other-organization exclusion, and embedding failure behavior without hitting a real provider.
- Analysis tests cover missing config, provider error, schema validation failure, retrieval degradation, and history redaction without hitting a real provider.
- AI route tests cover missing `ai:read`, missing `elder:read`, missing `knowledge:read`, missing `knowledge:manage`, invalid request body, service failure status propagation, and success audit behavior.
- Citation normalization no longer appends unused allowed citations.
- Any unknown citation ID in findings/recommendations causes a structured schema validation failure and failed history record.
- Knowledge UI and AI analysis dialog keep sensitive details out of failure messages while making configuration/vector-generation failures understandable.
- `ai:manage` permission description no longer overpromises current UI/API capability.
- `pnpm lint`, `pnpm type-check`, `pnpm test`, and `pnpm build` pass.
## Out of Scope
- Async generation jobs.
- Streaming AI responses.
- AI usage billing/rate limiting.
- AI configuration UI.
- File upload/OCR/web crawling knowledge ingestion.
- AI-generated business record drafts or one-click mutations.

View File

@@ -0,0 +1,26 @@
{
"id": "07-06-harden-ai-analysis-path",
"name": "07-06-harden-ai-analysis-path",
"title": "Harden AI Analysis Path",
"description": "Add tests and tighten citation evidence chain for the AI knowledge analysis MVP.",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-05",
"completedAt": "2026-07-06",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

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": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-08",
"completedAt": "2026-07-09",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,80 @@
# AI Presentation And Fee Workspace Design
## Scope
This task keeps the current AI product surface and adds one new frontend-only fee workspace. It intentionally does not add a separate AI center, a billing database schema, or external finance integrations.
## AI Boundary
The visible AI workflow remains split across the existing layers:
- `modules/ai/server/analysis.ts` keeps permission checks, elder context lookup, prepared structured outputs, persisted history metadata, and audit logging.
- `modules/ai/components/ElderAiAnalysisDialog.tsx` keeps loading history and requesting a refreshed analysis through the existing API.
- `modules/dashboard/components/DashboardHome.tsx` continues to present the organization-scoped prepared analysis board.
- `modules/ai/components/KnowledgeManagementClient.tsx` remains persisted knowledge CRUD, but user-facing copy describes business value rather than retrieval chunks or implementation details.
No external model provider is called by the visible analysis path. The implementation pass will audit visible wording and remove provider, local retrieval, seed, synthetic, mock, and demo language without weakening permission or restricted-result behavior.
## Fee Workspace Boundary
Create a new `modules/billing/` frontend domain:
- `types.ts` owns fee account, charge item, payment, status, and summary types plus display constants and pure calculation helpers.
- `lib/presentation-data.ts` owns the initial organization fee ledger used by the workspace.
- `components/BillingWorkspaceClient.tsx` owns all browser-session interactions.
Create routes:
- `app/(app)/app/billing/page.tsx`
- `app/(app)/app/[organizationSlug]/billing/page.tsx`
The server route performs the normal authentication and organization checks, gates the page with `admission:manage`, and passes the initial ledger into the client component. No API route or database table is added.
## Navigation
Add `费用管理` to the operations group after `床位房间`. Reuse `admission:manage` so only current institution administrators and operations managers see and operate the workspace. Add a Lucide wallet/receipt icon through the existing sidebar icon map.
## Fee Data Model
Each resident statement contains:
- statement id, resident identity, room/bed label, billing period, due date
- charge items with category, description, quantity, unit price, and amount
- payment records with amount, method, time, reference, operator, and note
- invoice state and receipt count
Totals and status are derived from charge and payment records:
- `paid`: outstanding amount is zero
- `partial`: at least one payment exists and an outstanding amount remains
- `pending`: no payment and not past due
- `overdue`: outstanding amount remains after the due date
All currency is represented as integer cents internally and formatted as CNY for display.
## Interactions
The fee workspace provides:
- overview metrics for current receivables, collected amount, outstanding amount, and overdue households
- resident/statement search and status filtering
- statement detail dialog with itemized charges and payment history
- charge registration dialog that appends a new item and recalculates totals
- payment registration dialog with amount and method validation, then local statement/status updates
- receipt and invoice actions that update the current browser-session record and return credible product feedback
Controls must not be decorative. Every visible command either changes local state, opens usable detail, or returns explicit success/error feedback.
## Responsive Layout
- Use the existing `max-w-7xl`, operational header, metric cards, table, badge, select, input, button, and dialog patterns.
- Keep tables horizontally scrollable with explicit minimum widths.
- Stack toolbar controls and dialog form fields at narrow widths.
- Keep touch targets at least the existing button height and avoid text overlap.
## Compatibility And Rollback
- Existing AI API and database contracts remain unchanged.
- No migration or production data backfill is required.
- Rollback consists of removing the billing route/module/nav item and reverting the small AI copy edits.
- The task-specific requirement for a frontend-only fee presentation workspace is an intentional exception to the generic persisted-operational-module guideline.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,44 @@
# AI Presentation And Fee Workspace Implementation Plan
## 1. AI Surface Audit
- [x] Confirm the visible elder analysis path has no external provider call.
- [x] Replace technical knowledge retrieval and failure-history wording with product-facing copy.
- [x] Keep analysis history, refresh/generate behavior, citations, and permission redaction intact.
- [x] Add or adjust focused assertions for user-visible AI copy where practical.
## 2. Fee Domain
- [x] Add billing types, labels, currency helpers, status derivation, and summary calculations.
- [x] Add realistic initial resident statements with itemized fees and payments.
- [x] Add unit tests for amount totals, payment application, and status derivation.
## 3. Fee Workspace UI
- [x] Build overview metrics, search, filters, and responsive statements table.
- [x] Build statement detail with charge items and payment history.
- [x] Build charge registration and payment registration flows with validation.
- [x] Build receipt and invoice actions with state updates and visible feedback.
## 4. Routing And Navigation
- [x] Add scoped and unscoped billing routes with authentication and `admission:manage` gating.
- [x] Add `费用管理` and a Lucide icon to the operations navigation.
- [x] Verify workspace breadcrumbs and organization-scoped links resolve correctly.
## 5. Validation
- [x] Run focused billing and AI tests.
- [x] Run `pnpm lint`.
- [x] Run `pnpm type-check`.
- [x] Run `pnpm test`.
- [x] Run `pnpm build`.
- [x] Start the development server and smoke-test AI and billing flows in the browser at desktop and mobile widths.
- [x] Check browser console errors, text clipping, dialog geometry, and interactive state updates.
## Risk And Rollback Points
- Keep the billing domain isolated so removal does not affect persisted modules.
- Do not change `Permission`, role seed, schema, or migrations for the frontend-only workspace.
- Reuse `admission:manage`; changing authorization is out of scope.
- Do not alter AI API response shapes or stored analysis records.

View File

@@ -0,0 +1,43 @@
# AI Presentation And Fee Workspace PRD
## Goal
Make the product presentation-ready by clarifying and stabilizing the visible AI workflows, then add a complete-looking fee management workspace that behaves like a normal product feature without exposing implementation shortcuts in the UI.
## Background
- The current elder AI analysis no longer calls an external model provider. `modules/ai/server/analysis.ts` builds prepared structured analysis outputs while preserving permission checks, elder context lookup, history records, and audit behavior.
- The dashboard AI board also renders prepared analysis content and fills missing records with generated presentation data.
- The elder AI dialog still uses the AI API for history loading and analysis generation, and knowledge management still uses persisted CRUD APIs.
- The product has no fee, billing, payment, invoice, or receipt workspace, permission, API, or database model.
- Earlier project scope treated fee records as a useful complete-product capability while payment settlement, insurance integration, and external finance integrations were deferred.
## Requirements
1. Audit all visible AI entry points and remove wording or interaction behavior that exposes provider, seed, synthetic, mock, or demo implementation details.
2. Keep AI interactions stable and immediately usable in a presentation environment without depending on an external model service.
3. Preserve the existing authorization and resident data-scope presentation behavior for AI analysis.
4. Add a discoverable fee management workspace under the main operations navigation.
5. Implement the fee workspace as frontend-only interactive state with realistic resident account data and no new database schema or payment integration.
6. The fee workspace must cover an operational loop rather than a static screen: overview metrics, account search/filtering, charge detail, payment registration, and receipt/invoice-oriented actions.
7. All user-visible labels and messages must read as production product copy. Do not display `mock`, `demo`, `sample`, `seed`, `synthetic`, `placeholder`, or equivalent Chinese wording.
8. Keep visual structure consistent with the existing quiet operational dashboard, table, badge, dialog, and responsive layout patterns.
## Acceptance Criteria
- [x] Every visible AI route and action has been classified as prepared/local, persisted backend, or external-provider dependent, and the final visible workflow has no external-provider dependency.
- [x] Elder AI analysis opens with usable history, can produce a new structured result, and never exposes implementation-specific wording.
- [x] Knowledge management remains coherent with the chosen AI presentation scope and contains no technical copy about local retrieval chunks or provider configuration.
- [x] A `费用管理` navigation entry opens in both scoped and unscoped workspace routes.
- [x] The fee page shows realistic totals for receivables, collected amount, outstanding amount, and overdue accounts.
- [x] Users can search and filter resident accounts, open a statement/detail view, register a payment, and see the affected account totals/status update in the current browser session.
- [x] Users can invoke receipt or invoice-oriented actions with credible success feedback and without dead controls.
- [x] The fee workspace is usable at desktop and mobile widths without overlapping controls or clipped text.
- [x] Lint, type-check, focused tests, production build, and browser smoke verification pass.
## Out Of Scope
- Persisting fee data to PostgreSQL.
- Real payment gateways, refunds, reconciliation files, tax invoices, insurance, or medical reimbursement integrations.
- Changing the core authorization model solely for the frontend fee workspace.
- Adding a new external AI provider path or restoring model calls.

View File

@@ -0,0 +1,26 @@
{
"id": "ai-fee-frontend",
"name": "ai-fee-frontend",
"title": "梳理 AI 功能并补齐费用前端",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-09",
"completedAt": "2026-07-09",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,2 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file": ".trellis/workflow.md", "reason": "核验任务验收与 Git 操作边界"}

View File

@@ -0,0 +1,2 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file": ".trellis/workflow.md", "reason": "遵循任务执行、Git 安全边界与收尾流程"}

View File

@@ -0,0 +1,31 @@
# 核查并同步 Git 远端
## Goal
确认本地工作区及 `main` 分支的全部已提交内容已同步到项目配置的云端 Git 仓库,避免本地提交遗漏。
## Background
- 当前工作树干净,没有未提交或未跟踪文件。
- 本地 `main` 当前提交为 `2682a509dcbbe0ab7b1e3226a5363d580adf8763`
- `main` 跟踪 `gitea/main`,本地领先 3 个提交。
- `github``origin` 指向同一 GitHub 仓库地址;`gitea` 指向独立 Gitea 仓库。
## Requirements
- 拉取各唯一远端的最新引用,准确比较本地 `main` 与云端 `main` 的提交关系。
- 若远端没有本地未知提交,则以普通、非强制方式推送本地 `main`
- 不修改代码、提交历史、分支结构或远端配置。
- 同一仓库地址只需推送一次,避免对 `github` / `origin` 重复操作。
- 推送后再次核对工作树与各目标远端引用。
## Acceptance Criteria
- [ ] `git status` 显示工作树干净。
- [ ] 本地 `main` 的已提交内容已推送到 Gitea 与 GitHub 两个唯一远端仓库。
- [ ] 推送后各目标远端 `main` 与本地 `main` 指向同一提交。
- [ ] 未使用强制推送,也未重写提交历史或更改远端配置。
## Out of Scope
- 创建新提交、修改项目文件、调整 Git 托管配置或创建其他分支/标签。

View File

@@ -0,0 +1,26 @@
{
"id": "sync-git-remotes",
"name": "sync-git-remotes",
"title": "核查并同步 Git 远端",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-27",
"completedAt": "2026-07-27",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 11
- **Last Active**: 2026-07-03
- **Total Sessions**: 17
- **Last Active**: 2026-07-27
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~382 | Active |
| `journal-1.md` | ~585 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,12 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 17 | 2026-07-27 | 核查并同步 Git 远端 | `4efb8ea` | `main` |
| 16 | 2026-07-09 | Polish AI presentation, add fee workspace, and deploy | `ef4dd21` | `main` |
| 15 | 2026-07-09 | Complete ops AI board and deploy latency controls | `a8776f9`, `153c501` | `main` |
| 14 | 2026-07-06 | Harden AI analysis path | `6ed7508`, `ae561a7`, `0d5093a`, `f74b7f3` | `main` |
| 13 | 2026-07-05 | AI knowledge analysis MVP | `e204974` | `main` |
| 12 | 2026-07-03 | Invitation limits and theme toggle | `fb15d4d`, `9fd2088` | `main` |
| 11 | 2026-07-03 | Build collaboration modules | `4ba2a11` | `main` |
| 10 | 2026-07-03 | Local mock data | `36eae0b` | `main` |
| 9 | 2026-07-03 | Settings dialog layout cleanup | `d240461` | `main` |

View File

@@ -380,3 +380,206 @@ Implemented real data-backed collaboration workspaces for devices, notices, aler
### Next Steps
- None - task complete
## Session 12: Invitation limits and theme toggle
**Date**: 2026-07-03
**Task**: Invitation limits and theme toggle
**Branch**: `main`
### Summary
Added workspace theme switching and configurable invitation validity/use limits with migration, backend validation, registration consumption, UI display, and Trellis specs.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `fb15d4d` | (see git log) |
| `9fd2088` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 13: AI knowledge analysis MVP
**Date**: 2026-07-05
**Task**: AI knowledge analysis MVP
**Branch**: `main`
### Summary
Implemented LangChain-backed elder AI analysis with pgvector knowledge retrieval, permissions, APIs, UI entry points, migrations, and specs. Validated lint, type-check, tests, build, and local pgvector migration smoke checks.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `e204974` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 14: Harden AI analysis path
**Date**: 2026-07-06
**Task**: Harden AI analysis path
**Branch**: `main`
### Summary
Hardened elder AI analysis and knowledge retrieval: graceful degradation, provider response validation, regression tests, JSONB embeddings without pgvector, production AI env configured, wlcb1 deployed and smoke-tested.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `6ed7508` | (see git log) |
| `ae561a7` | (see git log) |
| `0d5093a` | (see git log) |
| `f74b7f3` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 15: Complete ops AI board and deploy latency controls
**Date**: 2026-07-09
**Task**: Complete ops AI board and deploy latency controls
**Branch**: `main`
### Summary
Completed operations dashboard AI board work, bounded elder AI provider latency with sanitized timeout failures, verified tests/build, pushed, and deployed release 202607091628-153c501 to wlcb1.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `a8776f9` | (see git log) |
| `153c501` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 16: Polish AI presentation, add fee workspace, and deploy
**Date**: 2026-07-09
**Task**: Polish AI presentation, add fee workspace, and deploy
**Branch**: `main`
### Summary
Added a frontend fee management workspace with billing totals, statement details, charge and payment registration, receipts, and invoices; polished AI and knowledge UI copy; validated desktop/mobile flows; pushed and deployed release 20260710080833-ef4dd21 to wlcb1 while restoring systemd as the sole process manager.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `ef4dd21` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 17: 核查并同步 Git 远端
**Date**: 2026-07-27
**Task**: 核查并同步 Git 远端
**Branch**: `main`
### Summary
核对本地工作区及 main 分支,将提交安全同步到 Gitea 与 GitHub并验证两个远端 main 与本地 HEAD 一致。
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `4efb8ea` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete

View File

@@ -0,0 +1,5 @@
import BillingPage from "../../billing/page";
export default async function ScopedBillingPage(): Promise<React.ReactElement> {
return BillingPage();
}

View File

@@ -0,0 +1,5 @@
import KnowledgePage from "../../../settings/knowledge/page";
export default async function ScopedKnowledgePage(): Promise<React.ReactElement> {
return KnowledgePage();
}

View File

@@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
import { BillingWorkspaceClient } from "@/modules/billing/components/BillingWorkspaceClient";
import { createInitialBillingStatements } from "@/modules/billing/lib/presentation-data";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { hasPermission } from "@/modules/core/server/permissions";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function BillingPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!context.organization || !hasPermission(context.permissions, "admission:manage")) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
return <BillingWorkspaceClient initialStatements={createInitialBillingStatements()} />;
}

View File

@@ -1,5 +1,7 @@
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 {
listOperationalAdmissions,
@@ -11,12 +13,27 @@ import { hasPermission } from "@/modules/core/server/permissions";
import type { BedStatus, Permission } from "@/modules/core/types";
import { listCareExecutionData } from "@/modules/care/server/operations";
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
import { listDeviceOperationsData } from "@/modules/devices/server/operations";
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
import { listFamilyServiceData } from "@/modules/family/server/operations";
import { listHealthAdminData } from "@/modules/health/server/operations";
import { listNoticeCenterData } from "@/modules/notices/server/operations";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
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> {
const context = await getCurrentAuthContext();
@@ -30,18 +47,31 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
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 canReadHealth = hasPermission(context.permissions, "health: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([
listOperationalElders(organizationId),
listOperationalBeds(organizationId),
listOperationalAdmissions(organizationId),
listOperationalIncidents(organizationId),
const [elders, beds, admissions, incidents, careData, healthData, emergencyData, deviceData, noticeData, alertData, familyData, aiBoardResult] = await Promise.all([
organizationId && canReadElders ? listOperationalElders(organizationId) : [],
organizationId && (canReadFacility || canReadAdmissions) ? listOperationalBeds(organizationId) : [],
organizationId && canReadAdmissions ? listOperationalAdmissions(organizationId) : [],
organizationId && canReadIncidents ? listOperationalIncidents(organizationId) : [],
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
organizationId && canReadHealth ? listHealthAdminData(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;
@@ -56,31 +86,50 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
const openEmergency = emergencyData?.metrics.open ?? 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[] = [
{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" },
{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" },
{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" },
{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" },
{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" },
{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" },
...(canReadElders ? [{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" as const }] : []),
...(canReadFacility || canReadAdmissions ? [{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" as const }] : []),
...(canReadCare ? [{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" as const }] : []),
...(canReadHealth ? [{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" as const }] : []),
...(canReadAdmissions ? [{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" as const }] : []),
...(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,
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,
elderName: admission.elderName,
bedLabel: `${admission.roomName}-${admission.bedCode}`,
status: admission.status,
admittedAt: admission.admittedAt,
dischargedAt: admission.dischargedAt,
}));
}))
: undefined;
const visibleIncidents = incidents
const visibleIncidents = canReadIncidents
? incidents
.filter((incident) => incident.status !== "closed")
.slice(0, 6)
.map((incident) => ({
@@ -90,8 +139,9 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
title: incident.title,
source: incident.source,
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")
.slice(0, 6)
.map((task) => ({
@@ -103,7 +153,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
status: task.status,
title: task.title,
}));
const healthReviews = (healthData?.reviews ?? [])
const healthReviews = healthData?.reviews
.filter((review) => review.status === "pending" || review.severity === "critical")
.slice(0, 6)
.map((review) => ({
@@ -113,7 +163,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
status: review.status,
title: review.title,
}));
const emergencyIncidents = (emergencyData?.incidents ?? [])
const emergencyIncidents = emergencyData?.incidents
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
.slice(0, 6)
.map((incident) => ({
@@ -124,18 +174,76 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
source: incident.source,
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 (
<DashboardHome
alertTriggers={alertTriggers}
alertsHref={getWorkspaceHref(context.organization?.slug, "/alerts")}
aiBoardItems={canReadAi ? aiBoardItems ?? [] : undefined}
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
careHref={getWorkspaceHref(context.organization?.slug, "/care")}
careTasks={careTasks}
deviceTickets={deviceTickets}
devicesHref={getWorkspaceHref(context.organization?.slug, "/devices")}
eldersHref={getWorkspaceHref(context.organization?.slug, "/elders")}
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
emergencyIncidents={emergencyIncidents}
familyFeedback={familyFeedback}
familyHref={getWorkspaceHref(context.organization?.slug, "/family")}
familyVisits={familyVisits}
healthHref={getWorkspaceHref(context.organization?.slug, "/health")}
healthReviews={healthReviews}
incidents={visibleIncidents}
metrics={metrics}
notices={notices}
noticesHref={getWorkspaceHref(context.organization?.slug, "/notices")}
occupancyData={occupancyData}
recentAdmissions={recentAdmissions}
/>

View File

@@ -1,5 +1,5 @@
import { renderHealthWorkspacePage } from "@/modules/health/components/HealthWorkspacePage";
import { renderHealthSettingsPage } from "@/modules/health/components/HealthWorkspacePage";
export default async function HealthSettingsPage(): Promise<React.ReactElement> {
return renderHealthWorkspacePage();
return renderHealthSettingsPage();
}

View File

@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { KnowledgeManagementClient } from "@/modules/ai/components/KnowledgeManagementClient";
import { listKnowledgeEntries } from "@/modules/ai/server/knowledge";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { hasPermission } from "@/modules/core/server/permissions";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function KnowledgePage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!hasPermission(context.permissions, "knowledge:read")) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const entries = await listKnowledgeEntries(context);
return <KnowledgeManagementClient initialEntries={entries} permissions={context.permissions} />;
}

View File

@@ -0,0 +1,343 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import {
createKnowledgeEntry,
deleteKnowledgeEntry,
listKnowledgeEntries,
updateKnowledgeEntry,
} from "@/modules/ai/server/knowledge";
import type { ElderAiAnalysisHistoryItem, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import type { AuthContext } from "@/modules/core/types";
vi.mock("@/modules/core/server/auth", () => ({
requirePermission: vi.fn(),
}));
vi.mock("@/modules/core/server/audit", () => ({
recordAuditLog: vi.fn(),
}));
vi.mock("@/modules/ai/server/analysis", () => ({
generateElderAiAnalysis: vi.fn(),
listElderAiAnalyses: vi.fn(),
}));
vi.mock("@/modules/ai/server/knowledge", () => ({
createKnowledgeEntry: vi.fn(),
deleteKnowledgeEntry: vi.fn(),
listKnowledgeEntries: vi.fn(),
updateKnowledgeEntry: vi.fn(),
}));
const account: AuthContext["account"] = {
id: "account-1",
name: "AI Admin",
email: "ai-admin@example.com",
avatarUrl: "",
role: "org_admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const organization: NonNullable<AuthContext["organization"]> = {
id: "org-1",
name: "TeaCare Home",
slug: "teacare",
status: "active",
registrationEnabled: true,
oidcEnabled: false,
oidcIssuerUrl: "",
oidcClientId: "",
oidcHasClientSecret: false,
oidcScopes: "",
oidcRedirectUri: "",
oidcAvatarClaim: "",
oidcAutoProvision: false,
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
function createAuthContext(): AuthContext {
return {
account,
organization,
organizations: [
{
id: organization.id,
name: organization.name,
slug: organization.slug,
status: organization.status,
registrationEnabled: organization.registrationEnabled,
oidcEnabled: organization.oidcEnabled,
roleLabel: "Admin",
isActive: true,
},
],
membership: {
id: "membership-1",
accountId: account.id,
organizationId: organization.id,
roleId: "role-1",
roleKey: "org_admin",
roleLabel: "Admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
},
permissions: ["ai:read", "elder:read", "knowledge:read", "knowledge:manage"],
session: {
id: "session-1",
accountId: account.id,
activeOrganizationId: organization.id,
createdAt: "2026-07-02T00:00:00.000Z",
expiresAt: "2026-07-09T00:00:00.000Z",
},
};
}
const analysis: ElderAiAnalysisHistoryItem = {
id: "analysis-1",
elderId: "elder-1",
status: "completed",
dataScopes: ["elder"],
createdAt: "2026-07-02T08:00:00.000Z",
restricted: false,
result: {
overallRiskLevel: "medium",
summary: "Resident has elevated fall risk at night.",
keyFindings: [
{
category: "fall-risk",
severity: "warning",
evidence: "Night wandering and prior fall history are present.",
citationIds: ["resident-1"],
},
],
recommendations: [
{
title: "Increase night checks",
priority: "high",
rationale: "Additional observation reduces missed night wandering events.",
suggestedNextStep: "Add a night-shift round note for the next care review.",
citationIds: ["resident-1"],
},
],
dataGaps: [],
citations: [
{
id: "resident-1",
sourceType: "resident_context",
sourceId: "elder-1",
title: "Resident risk notes",
excerpt: "Night wandering and prior fall history.",
},
],
confidence: 0.74,
modelSummary: {
provider: "openai-compatible",
chatModel: "gpt-test",
knowledgeRetrieval: "keyword",
},
},
};
const knowledgeInput: KnowledgeEntryInput = {
scope: "organization",
organizationId: "org-1",
title: "Fall prevention",
category: "Safety",
tags: "fall,night",
body: "Keep walkways clear and increase night rounds after wandering events.",
status: "enabled",
};
const knowledgeEntry: KnowledgeEntry = {
...knowledgeInput,
id: "knowledge-1",
createdAt: "2026-07-02T08:00:00.000Z",
updatedAt: "2026-07-02T08:00:00.000Z",
};
function allowAuth(): void {
vi.mocked(requirePermission).mockResolvedValue({ success: true, context: createAuthContext() });
}
function deniedAuth(reason = "权限不足", status = 403): { success: false; response: Response } {
return {
success: false,
response: Response.json({ success: false, reason }, { status }),
};
}
function jsonRequest(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/ai/knowledge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function readJson(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}
beforeEach(() => {
vi.clearAllMocks();
allowAuth();
});
describe("AI API routes", () => {
it("requires ai:read before elder analysis services are reached", async () => {
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
const { GET } = await import("./elders/[id]/analyses/route");
const response = await GET(new Request("http://localhost/api/ai/elders/elder-1/analyses"), {
params: Promise.resolve({ id: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body).toEqual({ success: false, reason: "权限不足" });
expect(requirePermission).toHaveBeenCalledTimes(1);
expect(requirePermission).toHaveBeenCalledWith("ai:read", expect.objectContaining({ targetId: "elder-1" }));
expect(listElderAiAnalyses).not.toHaveBeenCalled();
});
it("requires elder:read after ai:read before listing elder analysis history", async () => {
vi.mocked(requirePermission)
.mockResolvedValueOnce({ success: true, context: createAuthContext() })
.mockResolvedValueOnce(deniedAuth("权限不足", 403));
const { GET } = await import("./elders/[id]/analyses/route");
const response = await GET(new Request("http://localhost/api/ai/elders/elder-1/analyses"), {
params: Promise.resolve({ id: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body.reason).toBe("权限不足");
expect(requirePermission).toHaveBeenNthCalledWith(2, "elder:read", expect.objectContaining({
organizationId: "org-1",
targetId: "elder-1",
}));
expect(listElderAiAnalyses).not.toHaveBeenCalled();
});
it("propagates analysis service failures with the service status", async () => {
vi.mocked(generateElderAiAnalysis).mockResolvedValue({ success: false, reason: "AI_API_KEY 未配置", status: 500 });
const { POST } = await import("./elders/[id]/analyses/route");
const response = await POST(new Request("http://localhost/api/ai/elders/elder-1/analyses", { method: "POST" }), {
params: Promise.resolve({ id: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(500);
expect(body).toEqual({ success: false, reason: "AI_API_KEY 未配置" });
expect(generateElderAiAnalysis).toHaveBeenCalledWith(createAuthContext(), "elder-1");
});
it("returns structured success for generated analysis", async () => {
vi.mocked(generateElderAiAnalysis).mockResolvedValue({ success: true, data: { analysis } });
const { POST } = await import("./elders/[id]/analyses/route");
const response = await POST(new Request("http://localhost/api/ai/elders/elder-1/analyses", { method: "POST" }), {
params: Promise.resolve({ id: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(201);
expect(body).toEqual({ success: true, reason: "AI 分析已生成", analysis });
});
it("requires knowledge:read before listing knowledge entries", async () => {
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
const { GET } = await import("./knowledge/route");
const response = await GET();
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body.reason).toBe("权限不足");
expect(requirePermission).toHaveBeenCalledWith("knowledge:read", expect.objectContaining({ action: "ai.knowledge.list" }));
expect(listKnowledgeEntries).not.toHaveBeenCalled();
});
it("requires knowledge:manage before creating knowledge entries", async () => {
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput }));
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body.reason).toBe("权限不足");
expect(requirePermission).toHaveBeenCalledWith("knowledge:manage", expect.objectContaining({ action: "ai.knowledge.create" }));
expect(createKnowledgeEntry).not.toHaveBeenCalled();
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("rejects invalid knowledge bodies before calling the service or audit log", async () => {
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput, title: " " }));
const body = await readJson(response);
expect(response.status).toBe(400);
expect(body).toEqual({ success: false, reason: "知识标题不能为空" });
expect(createKnowledgeEntry).not.toHaveBeenCalled();
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("propagates knowledge service failures with the service status", async () => {
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: false, reason: "知识库创建失败", status: 500 });
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput }));
const body = await readJson(response);
expect(response.status).toBe(500);
expect(body).toEqual({ success: false, reason: "知识库创建失败" });
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("returns created knowledge entries and records create audit success", async () => {
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput }));
const body = await readJson(response);
expect(response.status).toBe(201);
expect(body).toEqual({ success: true, reason: "知识库条目已创建", entry: knowledgeEntry });
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
actor: account,
organizationId: "org-1",
action: "ai.knowledge.create",
targetType: "ai_knowledge",
targetId: "knowledge-1",
result: "success",
}));
});
it("records update and delete audit success only after knowledge mutations succeed", async () => {
vi.mocked(updateKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
vi.mocked(deleteKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
const { PATCH, DELETE } = await import("./knowledge/[id]/route");
const patchResponse = await PATCH(jsonRequest({ ...knowledgeInput }), {
params: Promise.resolve({ id: "knowledge-1" }),
});
const deleteResponse = await DELETE(new Request("http://localhost/api/ai/knowledge/knowledge-1", { method: "DELETE" }), {
params: Promise.resolve({ id: "knowledge-1" }),
});
expect(patchResponse.status).toBe(200);
expect(deleteResponse.status).toBe(200);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.knowledge.update", result: "success" }));
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.knowledge.delete", result: "success" }));
});
});

View File

@@ -0,0 +1,62 @@
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import { jsonFailure, jsonSuccess } from "@/modules/core/server/api";
import { requirePermission } from "@/modules/core/server/auth";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
async function requireAiAnalysisAccess(id: string) {
const aiAuth = await requirePermission("ai:read", {
action: "ai.elder_analysis.access",
targetType: "elder",
targetId: id,
});
if (!aiAuth.success) {
return aiAuth;
}
const elderAuth = await requirePermission("elder:read", {
action: "ai.elder_analysis.access",
targetType: "elder",
targetId: id,
organizationId: aiAuth.context.organization?.id,
});
if (!elderAuth.success) {
return elderAuth;
}
return aiAuth;
}
export async function GET(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requireAiAnalysisAccess(id);
if (!auth.success) {
return auth.response;
}
const result = await listElderAiAnalyses(auth.context, id);
if (!result.success) {
return jsonFailure(result.reason, result.status);
}
return jsonSuccess("AI 分析历史已加载", { history: result.data.history });
}
export async function POST(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requireAiAnalysisAccess(id);
if (!auth.success) {
return auth.response;
}
const result = await generateElderAiAnalysis(auth.context, id);
if (!result.success) {
return jsonFailure(result.reason, result.status);
}
return jsonSuccess("AI 分析已生成", { analysis: result.data.analysis }, 201);
}

View File

@@ -0,0 +1,74 @@
import { deleteKnowledgeEntry, updateKnowledgeEntry } from "@/modules/ai/server/knowledge";
import { validateKnowledgeEntryInput } from "@/modules/ai/types";
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
export async function PATCH(request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("knowledge:manage", {
action: "ai.knowledge.update",
targetType: "ai_knowledge",
targetId: id,
});
if (!auth.success) {
return auth.response;
}
const input = validateKnowledgeEntryInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const result = await updateKnowledgeEntry(auth.context, id, input.input);
if (!result.success) {
return jsonFailure(result.reason, result.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId: result.data.entry.organizationId ?? auth.context.organization?.id,
action: "ai.knowledge.update",
targetType: "ai_knowledge",
targetId: result.data.entry.id,
result: "success",
reason: `更新知识库条目:${result.data.entry.title}`,
});
return jsonSuccess("知识库条目已更新", { entry: result.data.entry });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("knowledge:manage", {
action: "ai.knowledge.delete",
targetType: "ai_knowledge",
targetId: id,
});
if (!auth.success) {
return auth.response;
}
const result = await deleteKnowledgeEntry(auth.context, id);
if (!result.success) {
return jsonFailure(result.reason, result.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId: result.data.entry.organizationId ?? auth.context.organization?.id,
action: "ai.knowledge.delete",
targetType: "ai_knowledge",
targetId: result.data.entry.id,
result: "success",
reason: `删除知识库条目:${result.data.entry.title}`,
});
return jsonSuccess("知识库条目已删除", { entry: result.data.entry });
}

View File

@@ -0,0 +1,50 @@
import { createKnowledgeEntry, listKnowledgeEntries } from "@/modules/ai/server/knowledge";
import { validateKnowledgeEntryInput } from "@/modules/ai/types";
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
export async function GET(): Promise<Response> {
const auth = await requirePermission("knowledge:read", {
action: "ai.knowledge.list",
targetType: "ai_knowledge",
});
if (!auth.success) {
return auth.response;
}
const entries = await listKnowledgeEntries(auth.context);
return jsonSuccess("知识库已加载", { entries });
}
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("knowledge:manage", {
action: "ai.knowledge.create",
targetType: "ai_knowledge",
});
if (!auth.success) {
return auth.response;
}
const input = validateKnowledgeEntryInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const result = await createKnowledgeEntry(auth.context, input.input);
if (!result.success) {
return jsonFailure(result.reason, result.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId: result.data.entry.organizationId ?? auth.context.organization?.id,
action: "ai.knowledge.create",
targetType: "ai_knowledge",
targetId: result.data.entry.id,
result: "success",
reason: `创建知识库条目:${result.data.entry.title}`,
});
return jsonSuccess("知识库条目已创建", { entry: result.data.entry }, 201);
}

View File

@@ -2,6 +2,14 @@ import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import {
DEFAULT_INVITATION_MAX_USES,
DEFAULT_INVITATION_VALIDITY_DAYS,
MAX_INVITATION_MAX_USES,
MAX_INVITATION_VALIDITY_DAYS,
MIN_INVITATION_MAX_USES,
MIN_INVITATION_VALIDITY_DAYS,
} from "@/modules/core/invitation-limits";
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
@@ -23,6 +31,25 @@ function readString(source: Record<string, unknown>, key: string): string {
return typeof value === "string" ? value.trim() : "";
}
function readBoundedInteger(
source: Record<string, unknown>,
key: string,
options: {
defaultValue: number;
min: number;
max: number;
label: string;
},
): { success: true; value: number } | { success: false; reason: string } {
const rawValue = source[key];
const value = rawValue === undefined || rawValue === null ? options.defaultValue : Number(rawValue);
if (!Number.isInteger(value) || value < options.min || value > options.max) {
return { success: false, reason: `${options.label}需为 ${options.min}-${options.max} 的整数` };
}
return { success: true, value };
}
function createInvitationToken(): string {
return randomBytes(24).toString("base64url");
}
@@ -55,6 +82,26 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
return jsonFailure("请选择邀请角色");
}
const validityDaysResult = readBoundedInteger(body, "validityDays", {
defaultValue: DEFAULT_INVITATION_VALIDITY_DAYS,
min: MIN_INVITATION_VALIDITY_DAYS,
max: MAX_INVITATION_VALIDITY_DAYS,
label: "邀请有效期",
});
if (!validityDaysResult.success) {
return jsonFailure(validityDaysResult.reason);
}
const maxUsesResult = readBoundedInteger(body, "maxUses", {
defaultValue: DEFAULT_INVITATION_MAX_USES,
min: MIN_INVITATION_MAX_USES,
max: MAX_INVITATION_MAX_USES,
label: "最大使用次数",
});
if (!maxUsesResult.success) {
return jsonFailure(maxUsesResult.reason);
}
const database = getDatabase();
const organizationRows = await database.select().from(organizations).where(eq(organizations.id, id)).limit(1);
const organization = organizationRows[0];
@@ -81,7 +128,9 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
token: createInvitationToken(),
status: "active",
createdByAccountId: auth.context.account.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
expiresAt: new Date(Date.now() + validityDaysResult.value * 24 * 60 * 60 * 1000),
maxUses: maxUsesResult.value,
usedCount: 0,
})
.returning();
const invitation = rows[0];
@@ -96,7 +145,7 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
targetType: "organizationInvitation",
targetId: invitation.id,
result: "success",
reason: `创建机构邀请:${email || "通用邀请"}`,
reason: `创建机构邀请:${email || "通用邀请"},有效期 ${validityDaysResult.value} 天,最多使用 ${maxUsesResult.value}`,
});
return jsonSuccess("邀请已创建", { invitation }, 201);

View File

@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { seedDefaultWorkspaceData } from "@/modules/core/server/default-workspace-data";
import { getDatabase } from "@/modules/core/server/db";
import { seedOrganizationRoles } from "@/modules/core/server/permissions";
import { organizations } from "@/modules/core/server/schema";
@@ -95,6 +96,7 @@ export async function POST(request: Request): Promise<Response> {
}
await seedOrganizationRoles(organization.id);
await seedDefaultWorkspaceData(organization.id);
await recordAuditLog({
actor: auth.context.account,
organizationId: organization.id,

View File

@@ -63,6 +63,37 @@
--color-kumo-badge-green: oklch(0.48 0.11 155);
}
.dark {
--background: oklch(0.18 0.018 160);
--foreground: oklch(0.93 0.014 154);
--card: oklch(0.235 0.02 160);
--card-foreground: oklch(0.93 0.014 154);
--popover: oklch(0.22 0.022 160);
--popover-foreground: oklch(0.93 0.014 154);
--primary: oklch(0.69 0.13 154);
--primary-foreground: oklch(0.15 0.022 158);
--secondary: oklch(0.29 0.026 160);
--secondary-foreground: oklch(0.9 0.018 154);
--muted: oklch(0.27 0.02 160);
--muted-foreground: oklch(0.72 0.02 154);
--accent: oklch(0.39 0.07 154);
--accent-foreground: oklch(0.94 0.018 154);
--destructive: oklch(0.64 0.19 25);
--destructive-foreground: oklch(0.98 0.012 25);
--border: oklch(0.36 0.024 160);
--input: oklch(0.38 0.024 160);
--ring: oklch(0.72 0.12 154);
--color-kumo-brand: var(--primary);
--color-kumo-brand-hover: oklch(0.76 0.13 154);
--color-kumo-focus: var(--ring);
--color-kumo-link: var(--primary);
--text-color-kumo-brand: var(--primary);
--text-color-kumo-link: var(--primary);
--color-kumo-success: oklch(0.7 0.13 154);
--color-kumo-success-tint: oklch(0.3 0.055 154);
--color-kumo-badge-green: oklch(0.69 0.13 154);
}
* {
border-color: var(--border);
}
@@ -192,7 +223,7 @@ a,
.teatea-button-secondary:hover,
.teatea-button-ghost:hover {
border-color: color-mix(in oklch, var(--primary) 45%, var(--border));
background: color-mix(in oklch, var(--secondary) 72%, white);
background: color-mix(in oklch, var(--secondary) 72%, var(--background));
color: var(--accent-foreground);
}
@@ -247,7 +278,7 @@ a,
.teatea-select-trigger:hover,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]):hover {
border-color: color-mix(in oklch, var(--primary) 45%, var(--input));
background: color-mix(in oklch, var(--secondary) 34%, white);
background: color-mix(in oklch, var(--secondary) 34%, var(--background));
}
.teatea-select-trigger[data-open="true"] {

View File

@@ -14,6 +14,21 @@ type RootLayoutProps = {
export default function RootLayout({ children }: RootLayoutProps): React.ReactElement {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
var theme = localStorage.getItem("teatea-theme") || "system";
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var isDark = theme === "dark" || (theme === "system" && prefersDark);
document.documentElement.classList.toggle("dark", isDark);
document.documentElement.style.colorScheme = isDark ? "dark" : "light";
} catch (_) {}
`,
}}
/>
</head>
<body>{children}</body>
</html>
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE "organization_invitations" ADD COLUMN "max_uses" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "organization_invitations" ADD COLUMN "used_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
UPDATE "organization_invitations" SET "used_count" = "max_uses" WHERE "status" <> 'active';

View File

@@ -0,0 +1,60 @@
CREATE TYPE "public"."ai_knowledge_scope" AS ENUM('platform', 'organization');--> statement-breakpoint
CREATE TYPE "public"."ai_knowledge_status" AS ENUM('enabled', 'disabled');--> statement-breakpoint
CREATE TYPE "public"."elder_ai_analysis_status" AS ENUM('completed', 'failed');--> statement-breakpoint
CREATE TABLE "ai_knowledge_chunks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"entry_id" uuid NOT NULL,
"organization_id" uuid,
"scope" "ai_knowledge_scope" NOT NULL,
"chunk_index" integer NOT NULL,
"content" text NOT NULL,
"embedding" jsonb DEFAULT '[]'::jsonb NOT NULL,
"source_title" text NOT NULL,
"source_category" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ai_knowledge_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"scope" "ai_knowledge_scope" NOT NULL,
"organization_id" uuid,
"title" text NOT NULL,
"category" text DEFAULT '' NOT NULL,
"tags" text DEFAULT '' NOT NULL,
"body" text NOT NULL,
"status" "ai_knowledge_status" DEFAULT 'enabled' NOT NULL,
"created_by_account_id" uuid,
"updated_by_account_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "elder_ai_analyses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"elder_id" uuid NOT NULL,
"actor_account_id" uuid,
"status" "elder_ai_analysis_status" NOT NULL,
"data_scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"result_json" jsonb,
"citations_json" jsonb DEFAULT '[]'::jsonb NOT NULL,
"model_summary_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"error_category" text DEFAULT '' NOT NULL,
"error_reason" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ai_knowledge_chunks" ADD CONSTRAINT "ai_knowledge_chunks_entry_id_ai_knowledge_entries_id_fk" FOREIGN KEY ("entry_id") REFERENCES "public"."ai_knowledge_entries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ai_knowledge_chunks" ADD CONSTRAINT "ai_knowledge_chunks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ai_knowledge_entries" ADD CONSTRAINT "ai_knowledge_entries_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ai_knowledge_entries" ADD CONSTRAINT "ai_knowledge_entries_created_by_account_id_accounts_id_fk" FOREIGN KEY ("created_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ai_knowledge_entries" ADD CONSTRAINT "ai_knowledge_entries_updated_by_account_id_accounts_id_fk" FOREIGN KEY ("updated_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_actor_account_id_accounts_id_fk" FOREIGN KEY ("actor_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ai_knowledge_chunks_entry_lookup" ON "ai_knowledge_chunks" USING btree ("entry_id");--> statement-breakpoint
CREATE INDEX "ai_knowledge_chunks_scope_lookup" ON "ai_knowledge_chunks" USING btree ("scope","organization_id");--> statement-breakpoint
CREATE INDEX "ai_knowledge_entries_scope_lookup" ON "ai_knowledge_entries" USING btree ("scope","status");--> statement-breakpoint
CREATE INDEX "ai_knowledge_entries_organization_lookup" ON "ai_knowledge_entries" USING btree ("organization_id","status");--> statement-breakpoint
CREATE INDEX "elder_ai_analyses_elder_lookup" ON "elder_ai_analyses" USING btree ("organization_id","elder_id","created_at");--> statement-breakpoint
CREATE INDEX "elder_ai_analyses_status_lookup" ON "elder_ai_analyses" USING btree ("organization_id","status");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,20 @@
"when": 1783071837853,
"tag": "0005_quiet_sandman",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1783077936270,
"tag": "0006_petite_night_nurse",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1783185764378,
"tag": "0007_purple_puff_adder",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,292 @@
"use client";
import { useEffect, useState } from "react";
import { Brain, RefreshCw, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import type {
ElderAiAnalysisHistoryItem,
ElderAiDataScope,
ElderAiRecommendationPriority,
ElderAiSeverity,
} from "@/modules/ai/types";
import type { ApiResult } from "@/modules/core/server/api";
import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS } from "@/modules/elders/types";
import type { Elder } from "@/modules/elders/types";
type ElderAiAnalysisDialogProps = {
elder: Elder | null;
open: boolean;
onClose: () => void;
};
const riskLabels: Record<string, string> = {
low: "低",
medium: "中",
high: "高",
critical: "紧急",
unknown: "未知",
};
const statusLabels: Record<ElderAiAnalysisHistoryItem["status"], string> = {
completed: "已完成",
failed: "未完成",
};
const severityLabels: Record<ElderAiSeverity, string> = {
info: "提示",
warning: "关注",
critical: "紧急",
};
const recommendationPriorityLabels: Record<ElderAiRecommendationPriority, string> = {
low: "低",
normal: "常规",
high: "高",
urgent: "紧急",
};
const dataScopeLabels: Record<ElderAiDataScope, string> = {
elder: "基础档案",
health: "健康数据",
care: "护理服务",
family: "家属服务",
admission: "入住信息",
facility: "床位设施",
alert: "规则预警",
incident: "安全事件",
knowledge: "知识库",
};
function severityVariant(severity: ElderAiSeverity): "danger" | "secondary" | "warning" {
if (severity === "critical") {
return "danger";
}
if (severity === "warning") {
return "warning";
}
return "secondary";
}
function recommendationVariant(priority: ElderAiRecommendationPriority): "danger" | "secondary" | "warning" {
if (priority === "urgent") {
return "danger";
}
if (priority === "high") {
return "warning";
}
return "secondary";
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisDialogProps): React.ReactElement {
const [history, setHistory] = useState<ElderAiAnalysisHistoryItem[]>([]);
const [message, setMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
async function loadHistory(target: Elder): Promise<void> {
setIsLoading(true);
setMessage("");
const response = await fetch(`/api/ai/elders/${target.id}/analyses`);
const result = (await response.json()) as ApiResult<{ history: ElderAiAnalysisHistoryItem[] }>;
setIsLoading(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setHistory(result.history);
}
useEffect(() => {
if (open && elder) {
void loadHistory(elder);
}
}, [elder, open]);
async function generate(): Promise<void> {
if (!elder) {
return;
}
setIsGenerating(true);
setMessage("");
const response = await fetch(`/api/ai/elders/${elder.id}/analyses`, { method: "POST" });
const result = (await response.json()) as ApiResult<{ analysis: ElderAiAnalysisHistoryItem }>;
setIsGenerating(false);
if (!result.success) {
setMessage(result.reason);
await loadHistory(elder);
return;
}
setHistory((current) => [result.analysis, ...current]);
setMessage(result.reason);
}
const latest = history[0];
const latestResult = latest && !latest.restricted ? latest.result : undefined;
return (
<Dialog
description={elder ? `${elder.name} 的授权数据范围内智能分析历史和最新结论。` : undefined}
onClose={onClose}
open={open}
title="智能照护分析"
width="wide"
>
{elder ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-md border bg-secondary/30 p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-lg font-semibold">{elder.name}</p>
<p className="text-sm text-muted-foreground">
{elder.age} · {CARE_LEVEL_LABELS[elder.careLevel]} · {ELDER_STATUS_LABELS[elder.status]} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={isLoading || isGenerating} onClick={() => void loadHistory(elder)} type="button" variant="outline">
<RefreshCw className="size-4" aria-hidden="true" />
</Button>
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
<Sparkles className="size-4" aria-hidden="true" />
{isGenerating ? "分析中" : "更新分析"}
</Button>
</div>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
{latestResult ? (
<Card>
<CardHeader className="gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription>{latest ? formatDateTime(latest.createdAt) : ""}</CardDescription>
</div>
<Badge variant={latestResult.overallRiskLevel === "critical" || latestResult.overallRiskLevel === "high" ? "destructive" : "secondary"}>
{riskLabels[latestResult.overallRiskLevel] ?? latestResult.overallRiskLevel}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-4">
<p className="text-sm leading-6">{latestResult.summary}</p>
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.keyFindings.map((finding, index) => (
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{finding.category}</span>
<Badge variant={severityVariant(finding.severity)}>{severityLabels[finding.severity]}</Badge>
</div>
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
</div>
))}
</div>
</section>
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.recommendations.map((recommendation, index) => (
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{recommendation.title}</span>
<Badge variant={recommendationVariant(recommendation.priority)}>
{recommendationPriorityLabels[recommendation.priority]}
</Badge>
</div>
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
<p className="mt-1">{recommendation.suggestedNextStep}</p>
</div>
))}
</div>
</section>
{latestResult.dataGaps.length > 0 ? (
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<ul className="grid gap-1 text-sm text-muted-foreground">
{latestResult.dataGaps.map((gap) => (
<li key={gap}>{gap}</li>
))}
</ul>
</section>
) : null}
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.citations.map((citation) => (
<div className="rounded-md bg-secondary/40 p-3 text-xs" key={citation.id}>
<p className="font-medium">
[{citation.id}] {citation.title}
</p>
<p className="mt-1 text-muted-foreground">{citation.excerpt}</p>
</div>
))}
</div>
</section>
</CardContent>
</Card>
) : latest ? (
<Card>
<CardHeader className="gap-2">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle></CardTitle>
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
</div>
<Badge variant={latest.status === "failed" ? "destructive" : "secondary"}>{latest.status === "failed" ? "生成失败" : "受限"}</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 text-sm text-muted-foreground">
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
{latest.status === "failed" ? <p></p> : null}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
{isLoading ? "正在加载分析历史" : "暂无智能分析历史"}
</CardContent>
</Card>
)}
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{history.map((item) => (
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
<div>
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
<p className="text-xs text-muted-foreground">
{item.dataScopes.map((scope) => dataScopeLabels[scope]).join("、") || "-"}
</p>
</div>
<div className="flex items-center gap-2">
{item.restricted ? <Badge variant="secondary"></Badge> : null}
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{statusLabels[item.status]}</Badge>
</div>
</div>
))}
</div>
</section>
</div>
) : (
<div className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
<Brain className="mr-2 size-4" aria-hidden="true" />
</div>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { Edit3, Plus, Search, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { AiKnowledgeScope, AiKnowledgeStatus, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
import type { ApiResult } from "@/modules/core/server/api";
import type { Permission } from "@/modules/core/types";
type KnowledgeManagementClientProps = {
initialEntries: KnowledgeEntry[];
permissions: Permission[];
};
type KnowledgeFormState = {
scope: AiKnowledgeScope;
title: string;
category: string;
tags: string;
body: string;
status: AiKnowledgeStatus;
};
const emptyForm: KnowledgeFormState = {
scope: "organization",
title: "",
category: "",
tags: "",
body: "",
status: "enabled",
};
const scopeOptions = [
{ value: "organization", label: "机构私有" },
{ value: "platform", label: "平台共享" },
];
const statusOptions = [
{ value: "enabled", label: "启用" },
{ value: "disabled", label: "停用" },
];
function entryToForm(entry: KnowledgeEntry): KnowledgeFormState {
return {
scope: entry.scope,
title: entry.title,
category: entry.category,
tags: entry.tags,
body: entry.body,
status: entry.status,
};
}
function matchesEntry(entry: KnowledgeEntry, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return true;
}
return [entry.title, entry.category, entry.tags, entry.body].join(" ").toLowerCase().includes(normalized);
}
export function KnowledgeManagementClient({ initialEntries, permissions }: KnowledgeManagementClientProps): React.ReactElement {
const [entries, setEntries] = useState(initialEntries);
const [query, setQuery] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<KnowledgeEntry | null>(null);
const [form, setForm] = useState<KnowledgeFormState>(emptyForm);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [message, setMessage] = useState("");
const canManage = permissions.includes("knowledge:manage");
const filteredEntries = useMemo(() => entries.filter((entry) => matchesEntry(entry, query)), [entries, query]);
const isEditing = editingId !== null;
function updateField<K extends keyof KnowledgeFormState>(key: K, value: KnowledgeFormState[K]): void {
setForm((current) => ({ ...current, [key]: value }));
}
function beginCreate(): void {
setEditingId(null);
setForm(emptyForm);
setMessage("");
setIsDialogOpen(true);
}
function beginEdit(entry: KnowledgeEntry): void {
setEditingId(entry.id);
setForm(entryToForm(entry));
setMessage("");
setIsDialogOpen(true);
}
function closeDialog(): void {
if (isPending) {
return;
}
setIsDialogOpen(false);
setEditingId(null);
setForm(emptyForm);
}
async function refreshEntries(): Promise<void> {
const response = await fetch("/api/ai/knowledge");
const result = (await response.json()) as ApiResult<{ entries: KnowledgeEntry[] }>;
if (result.success) {
setEntries(result.entries);
}
}
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManage) {
setMessage("当前角色无权维护知识库");
return;
}
setIsPending(true);
setMessage("");
const payload: KnowledgeEntryInput = {
...form,
};
const response = await fetch(isEditing ? `/api/ai/knowledge/${editingId}` : "/api/ai/knowledge", {
method: isEditing ? "PATCH" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
closeDialog();
setMessage(result.reason);
}
async function remove(): Promise<void> {
if (!deleteTarget || !canManage) {
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/ai/knowledge/${deleteTarget.id}`, { method: "DELETE" });
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
setDeleteTarget(null);
setMessage(result.reason);
}
async function toggleStatus(entry: KnowledgeEntry): Promise<void> {
const nextStatus: AiKnowledgeStatus = entry.status === "enabled" ? "disabled" : "enabled";
const response = await fetch(`/api/ai/knowledge/${entry.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...entry, status: nextStatus }),
});
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
setMessage(result.reason);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<Button disabled={!canManage} onClick={beginCreate} type="button">
<Plus className="size-4" aria-hidden="true" />
</Button>
</section>
<Card>
<CardHeader className="gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription> {filteredEntries.length} </CardDescription>
</div>
<label className="relative block w-full md:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input aria-label="搜索知识条目" className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
</label>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[860px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredEntries.map((entry) => (
<Table.Row key={entry.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{entry.title}</p>
<p className="max-w-md truncate text-xs text-muted-foreground">{entry.body}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3">{entry.scope === "platform" ? "平台共享" : "机构私有"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.category || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.tags || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={entry.status === "enabled" ? "success" : "secondary"}>
{entry.status === "enabled" ? "启用" : "停用"}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button disabled={!canManage} onClick={() => void toggleStatus(entry)} size="sm" type="button" variant="outline">
{entry.status === "enabled" ? "停用" : "启用"}
</Button>
<Button disabled={!canManage} onClick={() => beginEdit(entry)} size="sm" type="button" variant="outline">
<Edit3 className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canManage} onClick={() => setDeleteTarget(entry)} size="sm" type="button" variant="destructive">
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
{filteredEntries.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</CardContent>
</Card>
<Dialog
description="保存后将纳入智能分析的知识参考范围。"
onClose={closeDialog}
open={isDialogOpen}
title={isEditing ? "编辑知识" : "新增知识"}
width="lg"
>
<form className="grid gap-3" onSubmit={submit}>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="知识范围"
onValueChange={(value) => updateField("scope", value as AiKnowledgeScope)}
options={scopeOptions}
value={form.scope}
/>
</div>
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="知识状态"
onValueChange={(value) => updateField("status", value as AiKnowledgeStatus)}
options={statusOptions}
value={form.status}
/>
</div>
</div>
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("title", event.target.value)} required value={form.title} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("category", event.target.value)} value={form.category} />
</label>
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("tags", event.target.value)} placeholder="逗号分隔" value={form.tags} />
</label>
</div>
<label className="grid gap-1.5 text-sm font-medium">
<Textarea className="min-h-56" onChange={(event) => updateField("body", event.target.value)} required value={form.body} />
</label>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={closeDialog} type="button" variant="outline">
</Button>
<Button disabled={isPending || !canManage} type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
</Dialog>
<Dialog
description={deleteTarget ? `确认删除「${deleteTarget.title}」?对应分块也会删除。` : undefined}
onClose={() => setDeleteTarget(null)}
open={deleteTarget !== null}
title="删除确认"
width="sm"
>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={() => setDeleteTarget(null)} type="button" variant="outline">
</Button>
<Button disabled={isPending || !canManage} onClick={() => void remove()} type="button" variant="destructive">
</Button>
</div>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,568 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit";
import type { AppDatabase } from "@/modules/core/server/db";
import { getDatabase } from "@/modules/core/server/db";
import type { AuthContext, Permission } from "@/modules/core/types";
vi.mock("server-only", () => ({}));
vi.mock("@/modules/ai/server/elder-context", () => ({
buildElderAiContext: vi.fn(),
}));
vi.mock("@/modules/core/server/audit", () => ({
recordAuditLog: vi.fn(),
}));
vi.mock("@/modules/core/server/db", () => ({
getDatabase: vi.fn(),
}));
type AnalysisStatus = "completed" | "failed";
type AnalysisRow = {
id: string;
organizationId: string;
elderId: string;
actorAccountId: string | null;
status: AnalysisStatus;
dataScopes: string[];
resultJson: unknown;
citationsJson: unknown;
modelSummaryJson: unknown;
errorCategory: string;
errorReason: string;
createdAt: Date;
updatedAt: Date;
};
type ElderNameRow = {
id: string;
name: string;
};
type AnalysisInsertPayload = {
organizationId?: unknown;
elderId?: unknown;
actorAccountId?: unknown;
status?: unknown;
dataScopes?: unknown;
resultJson?: unknown;
citationsJson?: unknown;
modelSummaryJson?: unknown;
errorCategory?: unknown;
errorReason?: unknown;
};
type AnalysisDatabaseDouble = {
insertedValues: AnalysisInsertPayload[];
insert: ReturnlessFunction;
select: ReturnlessFunction;
};
type ReturnlessFunction = (...args: unknown[]) => unknown;
const preparedModelSummary = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
const placeholderWordsPattern = /mock|fake|demo|模拟|演示|示例/i;
const account: AuthContext["account"] = {
id: "account-1",
name: "Nurse Admin",
email: "admin@example.com",
avatarUrl: "",
role: "org_admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const organization: NonNullable<AuthContext["organization"]> = {
id: "org-1",
name: "TeaCare Home",
slug: "teacare",
status: "active",
registrationEnabled: true,
oidcEnabled: false,
oidcIssuerUrl: "",
oidcClientId: "",
oidcHasClientSecret: false,
oidcScopes: "",
oidcRedirectUri: "",
oidcAvatarClaim: "",
oidcAutoProvision: false,
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const residentCitation: AiCitation = {
id: "resident-1",
sourceType: "resident_context",
sourceId: "elder-1",
title: "Resident risk notes",
excerpt: "Night wandering and prior fall history.",
};
const carePlanCitation: AiCitation = {
id: "resident-2",
sourceType: "resident_context",
sourceId: "elder-1-care-plan",
title: "Current care plan",
excerpt: "Night checks and handover notes are reviewed each shift.",
};
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
function createAuthContext(permissions: Permission[] = ["ai:read", "elder:read", "health:read", "knowledge:read"]): AuthContext {
return {
account,
organization: organizationContext,
organizations: [
{
id: organizationContext.id,
name: organizationContext.name,
slug: organizationContext.slug,
status: organizationContext.status,
registrationEnabled: organizationContext.registrationEnabled,
oidcEnabled: organizationContext.oidcEnabled,
roleLabel: "Admin",
isActive: true,
},
],
membership: {
id: "membership-1",
accountId: account.id,
organizationId: organizationContext.id,
roleId: "role-1",
roleKey: "org_admin",
roleLabel: "Admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
},
permissions,
session: {
id: "session-1",
accountId: account.id,
activeOrganizationId: organizationContext.id,
createdAt: "2026-07-02T00:00:00.000Z",
expiresAt: "2026-07-09T00:00:00.000Z",
},
};
}
function createResidentContext(citations: AiCitation[] = [residentCitation]): ElderAiResidentContext {
return {
organizationId: "org-1",
elderId: "elder-1",
elderName: "王阿姨",
dataScopes: ["elder", "health"],
sections: ["Resident profile", "Health profile"],
citations,
promptContext: "Resident profile includes night wandering and prior fall history.",
};
}
function createAnalysisRow(values: AnalysisInsertPayload, index: number): AnalysisRow {
const status = values.status === "completed" || values.status === "failed" ? values.status : "failed";
const dataScopes = Array.isArray(values.dataScopes) ? values.dataScopes.filter((scope): scope is string => typeof scope === "string") : [];
return {
id: `analysis-${index + 1}`,
organizationId: typeof values.organizationId === "string" ? values.organizationId : "org-1",
elderId: typeof values.elderId === "string" ? values.elderId : "elder-1",
actorAccountId: typeof values.actorAccountId === "string" ? values.actorAccountId : null,
status,
dataScopes,
resultJson: values.resultJson ?? null,
citationsJson: values.citationsJson ?? [],
modelSummaryJson: values.modelSummaryJson ?? {},
errorCategory: typeof values.errorCategory === "string" ? values.errorCategory : "",
errorReason: typeof values.errorReason === "string" ? values.errorReason : "",
createdAt: new Date(`2026-07-02T0${index}:00:00.000Z`),
updatedAt: new Date(`2026-07-02T0${index}:00:00.000Z`),
};
}
function createDatabaseDouble(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseDouble {
const insertedValues: AnalysisInsertPayload[] = [];
const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
const insert = vi.fn(() => ({
values: vi.fn((values: AnalysisInsertPayload) => {
insertedValues.push(values);
return {
returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length - 1)]),
};
}),
}));
const select = vi.fn((selection?: unknown) => {
const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection;
return {
from: vi.fn(() => ({
where: vi.fn(() => {
if (selectsElderNames) {
return elderRows;
}
return {
orderBy: vi.fn(() => ({
limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)),
})),
};
}),
})),
};
});
return { insertedValues, insert, select };
}
function useDatabase(database: AnalysisDatabaseDouble): void {
vi.mocked(getDatabase).mockReturnValue(database as unknown as AppDatabase);
}
function useSuccessfulContext(citations?: AiCitation[]): void {
vi.mocked(buildElderAiContext).mockResolvedValue({
success: true,
context: createResidentContext(citations),
});
}
function fallbackCitationFor(elderId: string, elderName: string): AiCitation {
return {
id: `resident-${elderId}`,
sourceType: "resident_context",
sourceId: elderId,
title: `${elderName}综合照护档案`,
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
};
}
function expectNoPlaceholderWords(value: unknown): void {
const serialized = JSON.stringify(value);
expect(typeof serialized).toBe("string");
if (typeof serialized !== "string") {
return;
}
expect(serialized).not.toMatch(placeholderWordsPattern);
}
function expectPreparedAnalysisResult(value: unknown, elderName: string, citations: AiCitation[]): void {
expect(value).toEqual(expect.objectContaining({
summary: expect.stringContaining(elderName),
keyFindings: expect.arrayContaining([
expect.objectContaining({
citationIds: expect.arrayContaining([citations[0]?.id]),
}),
]),
recommendations: expect.arrayContaining([
expect.objectContaining({
citationIds: expect.arrayContaining([citations[0]?.id]),
}),
]),
citations,
modelSummary: preparedModelSummary,
}));
expectNoPlaceholderWords(value);
}
beforeEach(() => {
vi.clearAllMocks();
useSuccessfulContext();
useDatabase(createDatabaseDouble());
});
afterEach(() => {
vi.useRealTimers();
});
describe("elder AI analysis service", () => {
it("generates prepared analysis without provider configuration and records completed history", async () => {
const database = createDatabaseDouble();
useDatabase(database);
useSuccessfulContext([residentCitation, carePlanCitation]);
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
expect(buildElderAiContext).toHaveBeenCalledWith(expect.objectContaining({ account }), "elder-1");
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(database.insertedValues).toEqual([
expect.objectContaining({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder", "health"],
citationsJson: [residentCitation, carePlanCitation],
modelSummaryJson: preparedModelSummary,
}),
]);
expect(database.insertedValues[0]).not.toHaveProperty("errorCategory");
expect(database.insertedValues[0]).not.toHaveProperty("errorReason");
expectPreparedAnalysisResult(database.insertedValues[0]?.resultJson, "王阿姨", [residentCitation, carePlanCitation]);
expect(result.data.analysis).toEqual(expect.objectContaining({
id: "analysis-1",
elderId: "elder-1",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T00:00:00.000Z",
restricted: false,
}));
expectPreparedAnalysisResult(result.data.analysis.result, "王阿姨", [residentCitation, carePlanCitation]);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
action: "ai.elder_analysis.generate",
targetType: "elder",
targetId: "elder-1",
result: "success",
reason: "AI 分析已生成",
}));
});
it("lists stored failed rows as completed prepared history while preserving row identity and scopes", async () => {
const failedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "failed",
dataScopes: ["elder", "health"],
errorCategory: "provider_error",
errorReason: "upstream unavailable",
}, 0);
useDatabase(createDatabaseDouble([failedRow]));
useSuccessfulContext([residentCitation, carePlanCitation]);
const result = await listElderAiAnalyses(createAuthContext(), "elder-1");
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.history).toHaveLength(1);
expect(result.data.history[0]).toEqual(expect.objectContaining({
id: "analysis-1",
elderId: "elder-1",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T00:00:00.000Z",
restricted: false,
}));
expect(result.data.history[0]).not.toHaveProperty("errorCategory");
expect(result.data.history[0]).not.toHaveProperty("errorReason");
expectPreparedAnalysisResult(result.data.history[0]?.result, "王阿姨", [residentCitation, carePlanCitation]);
});
it("redacts prepared history converted from failed rows when stored scopes are not permitted", async () => {
const restrictedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "failed",
dataScopes: ["elder", "health"],
errorCategory: "provider_error",
errorReason: "provider detail should stay hidden",
}, 0);
useDatabase(createDatabaseDouble([restrictedRow]));
const result = await listElderAiAnalyses(createAuthContext(["ai:read", "elder:read"]), "elder-1");
expect(result).toEqual({
success: true,
data: {
history: [
{
id: "analysis-1",
elderId: "elder-1",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T00:00:00.000Z",
restricted: true,
},
],
},
});
});
it("synthesizes completed prepared history when no analysis rows are stored", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z"));
useDatabase(createDatabaseDouble([]));
useSuccessfulContext([residentCitation]);
const result = await listElderAiAnalyses(createAuthContext(), "elder-1");
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.history.map((item) => ({
id: item.id,
status: item.status,
createdAt: item.createdAt,
restricted: item.restricted,
dataScopes: item.dataScopes,
}))).toEqual([
{
id: "analysis-elder-1-1",
status: "completed",
createdAt: "2026-07-02T06:00:00.000Z",
restricted: false,
dataScopes: ["elder", "health"],
},
{
id: "analysis-elder-1-2",
status: "completed",
createdAt: "2026-07-02T00:00:00.000Z",
restricted: false,
dataScopes: ["elder", "health"],
},
{
id: "analysis-elder-1-3",
status: "completed",
createdAt: "2026-07-01T06:00:00.000Z",
restricted: false,
dataScopes: ["elder", "health"],
},
]);
for (const item of result.data.history) {
expectPreparedAnalysisResult(item.result, "王阿姨", [residentCitation]);
}
});
it("lists failed rows as completed board cards and fills missing elders with prepared items", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z"));
const failedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-2",
actorAccountId: "account-1",
status: "failed",
dataScopes: ["elder", "health"],
errorCategory: "timeout",
errorReason: "timeout details should stay hidden",
}, 0);
useDatabase(createDatabaseDouble(
[failedRow],
[
{ id: "elder-2", name: "李建国" },
{ id: "elder-4", name: "陈桂兰" },
],
));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 3);
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.items).toHaveLength(2);
expect(result.data.items[0]).toEqual(expect.objectContaining({
id: "analysis-1",
elderId: "elder-2",
elderName: "李建国",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T00:00:00.000Z",
restricted: false,
}));
expect(result.data.items[0]).not.toHaveProperty("errorCategory");
expect(result.data.items[0]).not.toHaveProperty("errorReason");
expectPreparedAnalysisResult(result.data.items[0]?.result, "李建国", [fallbackCitationFor("elder-2", "李建国")]);
expect(result.data.items[1]).toEqual(expect.objectContaining({
id: "analysis-elder-4-1",
elderId: "elder-4",
elderName: "陈桂兰",
status: "completed",
dataScopes: ["elder"],
createdAt: "2026-07-02T00:00:00.000Z",
restricted: false,
}));
expectPreparedAnalysisResult(result.data.items[1]?.result, "陈桂兰", [fallbackCitationFor("elder-4", "陈桂兰")]);
});
it("redacts board cards converted from failed rows when stored scopes are not permitted", async () => {
const restrictedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-3",
actorAccountId: "account-1",
status: "failed",
dataScopes: ["elder", "health"],
errorCategory: "provider_error",
errorReason: "provider detail should stay hidden",
}, 2);
useDatabase(createDatabaseDouble([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"],
}, 3);
const olderRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "failed",
dataScopes: ["elder"],
}, 0);
const middleRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-2",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
}, 1);
useDatabase(createDatabaseDouble(
[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" },
]);
});
});

View File

@@ -0,0 +1,455 @@
import "server-only";
import { and, desc, eq } from "drizzle-orm";
import { buildElderAiContext, type ElderAiResidentContext } from "@/modules/ai/server/elder-context";
import type {
AiCitation,
ElderAiAnalysisBoardItem,
ElderAiAnalysisHistoryItem,
ElderAiAnalysisOutput,
ElderAiDataScope,
} from "@/modules/ai/types";
import { canViewAnalysisScopes, parseDataScopes } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit";
import { getDatabase } from "@/modules/core/server/db";
import { elderAiAnalyses, elders } from "@/modules/core/server/schema";
import type { AuthContext } from "@/modules/core/types";
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
type ElderNameRow = { id: string; name: string };
const PREPARED_HISTORY_OFFSETS_MS = [0, 6, 24].map((hours) => hours * 60 * 60 * 1000);
const PREPARED_MODEL_SUMMARY = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
function toIsoString(value: Date): string {
return value.toISOString();
}
function createFallbackCitation(elderId: string, elderName: string): AiCitation {
return {
id: `resident-${elderId}`,
sourceType: "resident_context",
sourceId: elderId,
title: `${elderName}综合照护档案`,
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
};
}
function selectPreparedCitations(input: {
elderId: string;
elderName: string;
citations: AiCitation[];
}): { citations: AiCitation[]; primary: AiCitation; secondary: AiCitation; tertiary: AiCitation } {
const fallback = createFallbackCitation(input.elderId, input.elderName);
const citations = input.citations.length > 0 ? input.citations.slice(0, 3) : [fallback];
const primary = citations[0] ?? fallback;
const secondary = citations[1] ?? primary;
const tertiary = citations[2] ?? secondary;
return { citations, primary, secondary, tertiary };
}
function normalizeVariantIndex(index: number): 0 | 1 | 2 {
const normalized = Math.abs(Math.trunc(index)) % 3;
if (normalized === 1) {
return 1;
}
if (normalized === 2) {
return 2;
}
return 0;
}
function createPreparedAnalysisOutput(input: {
elderId: string;
elderName: string;
citations: AiCitation[];
variantIndex: number;
}): ElderAiAnalysisOutput {
const { citations, primary, secondary, tertiary } = selectPreparedCitations(input);
const variantIndex = normalizeVariantIndex(input.variantIndex);
if (variantIndex === 1) {
return {
overallRiskLevel: "medium",
summary: `${input.elderName}当前照护链路整体稳定,建议继续围绕生命体征复核、护理执行记录和家属沟通节点保持闭环。`,
keyFindings: [
{
category: "照护连续性",
severity: "info",
evidence: "基础档案和近期服务记录具备连续性,适合按班次保持观察和交接。",
citationIds: [primary.id],
},
{
category: "沟通安排",
severity: "info",
evidence: "近期重点可通过护理交接和家属同步降低信息差。",
citationIds: [secondary.id],
},
],
recommendations: [
{
title: "保持晨晚复核节奏",
priority: "normal",
rationale: "连续记录比单次观察更能支持照护排班和风险分层。",
suggestedNextStep: "责任护理员在交接前补齐当日观察、用药和护理执行摘要。",
citationIds: [primary.id],
},
{
title: "探访或回访前同步重点",
priority: "low",
rationale: "提前同步能减少家属沟通中的重复解释和遗漏。",
suggestedNextStep: "整理近期状态、护理安排和需家属关注事项后统一反馈。",
citationIds: [secondary.id],
},
],
dataGaps: ["缺少最近一次跨班次复核结论。"],
citations,
confidence: 0.78,
modelSummary: PREPARED_MODEL_SUMMARY,
};
}
if (variantIndex === 2) {
return {
overallRiskLevel: "high",
summary: `${input.elderName}近期需要重点关注夜间安全、异常信号复核和护理任务完成度,建议由护理组形成短周期追踪。`,
keyFindings: [
{
category: "夜间安全",
severity: "warning",
evidence: "近期记录显示夜间时段更需要巡视、体位和床旁环境复核。",
citationIds: [primary.id],
},
{
category: "任务闭环",
severity: "warning",
evidence: "护理任务需要明确责任人、完成时间和异常备注,避免跨班次遗漏。",
citationIds: [tertiary.id],
},
],
recommendations: [
{
title: "强化夜间巡视记录",
priority: "high",
rationale: "夜间异常通常依赖连续巡视和及时复核,单次口头交接不足以闭环。",
suggestedNextStep: "夜班增加床旁环境、呼叫器、体位和生命体征观察记录。",
citationIds: [primary.id],
},
{
title: "将重点事项纳入交接班",
priority: "high",
rationale: "跨班次事项需要明确下一次复核时间和责任人。",
suggestedNextStep: "在护理交接中标注未完成事项、复核时间和异常升级条件。",
citationIds: [tertiary.id],
},
],
dataGaps: ["缺少夜间巡视完成后的复盘记录。"],
citations,
confidence: 0.82,
modelSummary: PREPARED_MODEL_SUMMARY,
};
}
return {
overallRiskLevel: "high",
summary: `${input.elderName}近期照护重点集中在基础安全、健康复核和护理任务闭环,建议优先完成当日观察记录并同步责任护理组。`,
keyFindings: [
{
category: "综合风险",
severity: "warning",
evidence: "基础档案、近期服务记录和照护状态提示需要持续跟踪安全与健康变化。",
citationIds: [primary.id],
},
{
category: "执行闭环",
severity: "warning",
evidence: "照护安排需要结合任务记录、床位状态和异常备注持续复核。",
citationIds: [secondary.id],
},
],
recommendations: [
{
title: "完成当日重点观察",
priority: "high",
rationale: "把观察结果沉淀到护理记录,有助于后续班次快速判断变化趋势。",
suggestedNextStep: "责任护理员补充生命体征、进食、活动和睡眠观察摘要。",
citationIds: [primary.id],
},
{
title: "复核护理任务闭环",
priority: "normal",
rationale: "任务状态和备注能反映照护执行质量,适合每日例行复核。",
suggestedNextStep: "核对待处理任务、异常备注和下一次复核时间。",
citationIds: [secondary.id],
},
],
dataGaps: ["缺少最近一次完整护理交接摘要。"],
citations,
confidence: 0.84,
modelSummary: PREPARED_MODEL_SUMMARY,
};
}
function chooseDataScopes(scopes: ElderAiDataScope[], fallback: ElderAiDataScope[]): ElderAiDataScope[] {
return scopes.length > 0 ? scopes : fallback;
}
function createPreparedHistoryItem(input: {
id: string;
elderId: string;
elderName: string;
dataScopes: ElderAiDataScope[];
createdAt: string;
permissions: AuthContext["permissions"];
citations: AiCitation[];
variantIndex: number;
}): ElderAiAnalysisHistoryItem {
const restricted = !canViewAnalysisScopes(input.dataScopes, input.permissions);
const base = {
id: input.id,
elderId: input.elderId,
status: "completed" as const,
dataScopes: input.dataScopes,
createdAt: input.createdAt,
restricted,
};
if (restricted) {
return base;
}
return {
...base,
result: createPreparedAnalysisOutput({
elderId: input.elderId,
elderName: input.elderName,
citations: input.citations,
variantIndex: input.variantIndex,
}),
};
}
function rowToPreparedHistoryItem(
row: AnalysisRow,
residentContext: ElderAiResidentContext,
permissions: AuthContext["permissions"],
variantIndex: number,
): ElderAiAnalysisHistoryItem {
return createPreparedHistoryItem({
id: row.id,
elderId: row.elderId,
elderName: residentContext.elderName,
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), residentContext.dataScopes),
createdAt: toIsoString(row.createdAt),
permissions,
citations: residentContext.citations,
variantIndex,
});
}
function createSyntheticHistoryItems(
residentContext: ElderAiResidentContext,
permissions: AuthContext["permissions"],
): ElderAiAnalysisHistoryItem[] {
const now = Date.now();
return PREPARED_HISTORY_OFFSETS_MS.map((offsetMs, index) => createPreparedHistoryItem({
id: `analysis-${residentContext.elderId}-${index + 1}`,
elderId: residentContext.elderId,
elderName: residentContext.elderName,
dataScopes: residentContext.dataScopes,
createdAt: new Date(now - offsetMs).toISOString(),
permissions,
citations: residentContext.citations,
variantIndex: index,
}));
}
export async function listElderAiAnalyses(
context: AuthContext,
elderId: string,
): Promise<ServiceResult<{ history: ElderAiAnalysisHistoryItem[] }>> {
const organizationId = context.organization?.id;
if (!organizationId) {
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
}
const residentContext = await buildElderAiContext(context, elderId);
if (!residentContext.success) {
return { success: false, reason: residentContext.reason, status: residentContext.status };
}
const database = getDatabase();
const rows = await database
.select()
.from(elderAiAnalyses)
.where(and(eq(elderAiAnalyses.organizationId, organizationId), eq(elderAiAnalyses.elderId, elderId)))
.orderBy(desc(elderAiAnalyses.createdAt))
.limit(20);
return {
success: true,
data: {
history: rows.length > 0
? rows.map((row, index) => rowToPreparedHistoryItem(row, residentContext.context, context.permissions, index))
: createSyntheticHistoryItems(residentContext.context, context.permissions),
},
};
}
function createPreparedBoardItem(input: {
id: string;
elderId: string;
elderName: string;
dataScopes: ElderAiDataScope[];
createdAt: string;
permissions: AuthContext["permissions"];
variantIndex: number;
}): ElderAiAnalysisBoardItem {
return {
elderName: input.elderName,
...createPreparedHistoryItem({
id: input.id,
elderId: input.elderId,
elderName: input.elderName,
dataScopes: input.dataScopes,
createdAt: input.createdAt,
permissions: input.permissions,
citations: [createFallbackCitation(input.elderId, input.elderName)],
variantIndex: input.variantIndex,
}),
};
}
function createSyntheticBoardItems(input: {
elderRows: ElderNameRow[];
excludedElderIds: Set<string>;
remainingCount: number;
permissions: AuthContext["permissions"];
startIndex: number;
}): ElderAiAnalysisBoardItem[] {
const now = Date.now();
return input.elderRows
.filter((elder) => !input.excludedElderIds.has(elder.id))
.slice(0, input.remainingCount)
.map((elder, index) => {
const offset = PREPARED_HISTORY_OFFSETS_MS[(input.startIndex + index) % PREPARED_HISTORY_OFFSETS_MS.length] ?? 0;
return createPreparedBoardItem({
id: `analysis-${elder.id}-${index + 1}`,
elderId: elder.id,
elderName: elder.name,
dataScopes: ["elder"],
createdAt: new Date(now - offset).toISOString(),
permissions: input.permissions,
variantIndex: input.startIndex + index,
});
});
}
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]));
const itemsFromRows = rows.map((row, index) => createPreparedBoardItem({
id: row.id,
elderId: row.elderId,
elderName: elderNameById.get(row.elderId) ?? "未知老人",
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), ["elder"]),
createdAt: toIsoString(row.createdAt),
permissions: context.permissions,
variantIndex: index,
}));
const excludedElderIds = new Set(rows.map((row) => row.elderId));
const syntheticItems = createSyntheticBoardItems({
elderRows,
excludedElderIds,
remainingCount: rowLimit - itemsFromRows.length,
permissions: context.permissions,
startIndex: itemsFromRows.length,
});
return {
success: true,
data: {
items: [...itemsFromRows, ...syntheticItems].slice(0, rowLimit),
},
};
}
export async function generateElderAiAnalysis(
context: AuthContext,
elderId: string,
): Promise<ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>> {
const residentContext = await buildElderAiContext(context, elderId);
if (!residentContext.success) {
return { success: false, reason: residentContext.reason, status: residentContext.status };
}
const output = createPreparedAnalysisOutput({
elderId,
elderName: residentContext.context.elderName,
citations: residentContext.context.citations,
variantIndex: 0,
});
const database = getDatabase();
const rows = await database
.insert(elderAiAnalyses)
.values({
organizationId: residentContext.context.organizationId,
elderId,
actorAccountId: context.account.id,
status: "completed",
dataScopes: residentContext.context.dataScopes,
resultJson: output,
citationsJson: output.citations,
modelSummaryJson: output.modelSummary,
})
.returning();
const row = rows[0];
if (!row) {
return { success: false, reason: "AI 分析保存失败", status: 500 };
}
await recordAuditLog({
actor: context.account,
organizationId: residentContext.context.organizationId,
action: "ai.elder_analysis.generate",
targetType: "elder",
targetId: elderId,
result: "success",
reason: "AI 分析已生成",
});
return {
success: true,
data: {
analysis: rowToPreparedHistoryItem(row, residentContext.context, context.permissions, 0),
},
};
}

View File

@@ -0,0 +1,56 @@
export type AiRuntimeConfig = {
baseUrl: string;
apiKey: string;
chatModel: string;
requestTimeoutMs: number;
maxTokens: number;
maxRetries: number;
};
export type AiRuntimeConfigResult =
| { success: true; config: AiRuntimeConfig }
| { success: false; reason: string };
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 {
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 {
const apiKey = readOptionalEnv("AI_API_KEY");
if (!apiKey) {
return { success: false, reason: "AI_API_KEY 未配置" };
}
return {
success: true,
config: {
baseUrl: readOptionalEnv("AI_BASE_URL"),
apiKey,
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

@@ -0,0 +1,331 @@
import "server-only";
import { and, desc, eq } from "drizzle-orm";
import type { AiCitation, ElderAiDataScope } from "@/modules/ai/types";
import { getDatabase } from "@/modules/core/server/db";
import {
admissions,
alertTriggers,
beds,
careTasks,
chronicConditions,
elders,
familyFeedback,
healthAnomalyReviews,
healthProfiles,
rooms,
systemIncidents,
vitalRecords,
} from "@/modules/core/server/schema";
import type { AuthContext } from "@/modules/core/types";
type ElderAiContextResult =
| { success: true; context: ElderAiResidentContext }
| { success: false; reason: string; status: number };
export type ElderAiResidentContext = {
organizationId: string;
elderId: string;
elderName: string;
dataScopes: ElderAiDataScope[];
sections: string[];
citations: AiCitation[];
promptContext: string;
};
function hasPermission(context: AuthContext, permission: string): boolean {
return context.permissions.includes(permission as never);
}
function toIsoString(value: Date | null): string {
return value ? value.toISOString() : "";
}
function pushCitation(citations: AiCitation[], input: Omit<AiCitation, "id">): string {
const id = `ctx-${citations.length + 1}`;
citations.push({ id, ...input });
return id;
}
function compactText(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function appendSection(parts: string[], title: string, lines: string[]): void {
const compactLines = lines.map(compactText).filter(Boolean);
if (compactLines.length === 0) {
return;
}
parts.push(`## ${title}\n${compactLines.join("\n")}`);
}
export async function buildElderAiContext(context: AuthContext, elderId: string): Promise<ElderAiContextResult> {
const organizationId = context.organization?.id;
if (!organizationId) {
return { success: false, reason: "请选择机构后生成 AI 分析", status: 400 };
}
const database = getDatabase();
const elderRows = await database
.select()
.from(elders)
.where(and(eq(elders.id, elderId), eq(elders.organizationId, organizationId)))
.limit(1);
const elder = elderRows[0];
if (!elder) {
return { success: false, reason: "老人档案不存在", status: 404 };
}
const dataScopes: ElderAiDataScope[] = ["elder"];
const citations: AiCitation[] = [];
const sections: string[] = [];
const elderCitationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: elder.id,
title: "老人基础档案",
excerpt: `${elder.name}${elder.age} 岁,照护等级 ${elder.careLevel},状态 ${elder.status}`,
});
appendSection(sections, "老人基础档案", [
`[${elderCitationId}] 姓名:${elder.name};性别:${elder.gender};年龄:${elder.age};照护等级:${elder.careLevel};状态:${elder.status}`,
`主要联系人:${elder.primaryContact};电话:${elder.phone}`,
elder.medicalNotes ? `医疗备注:${elder.medicalNotes}` : "",
]);
const [
admissionRows,
healthProfileRows,
vitalRows,
conditionRows,
reviewRows,
careTaskRows,
alertRows,
feedbackRows,
incidentRows,
] = await Promise.all([
hasPermission(context, "admission:read") || hasPermission(context, "facility:read")
? database
.select({
admission: admissions,
bedCode: beds.code,
roomCode: rooms.code,
})
.from(admissions)
.leftJoin(beds, eq(beds.id, admissions.bedId))
.leftJoin(rooms, eq(rooms.id, beds.roomId))
.where(and(eq(admissions.organizationId, organizationId), eq(admissions.elderId, elderId)))
.orderBy(desc(admissions.admittedAt))
.limit(8)
: Promise.resolve([]),
hasPermission(context, "health:read")
? database
.select()
.from(healthProfiles)
.where(and(eq(healthProfiles.organizationId, organizationId), eq(healthProfiles.elderId, elderId)))
.limit(1)
: Promise.resolve([]),
hasPermission(context, "health:read")
? database
.select()
.from(vitalRecords)
.where(and(eq(vitalRecords.organizationId, organizationId), eq(vitalRecords.elderId, elderId)))
.orderBy(desc(vitalRecords.recordedAt))
.limit(8)
: Promise.resolve([]),
hasPermission(context, "health:read")
? database
.select()
.from(chronicConditions)
.where(and(eq(chronicConditions.organizationId, organizationId), eq(chronicConditions.elderId, elderId)))
.orderBy(desc(chronicConditions.updatedAt))
.limit(8)
: Promise.resolve([]),
hasPermission(context, "health:read")
? database
.select()
.from(healthAnomalyReviews)
.where(and(eq(healthAnomalyReviews.organizationId, organizationId), eq(healthAnomalyReviews.elderId, elderId)))
.orderBy(desc(healthAnomalyReviews.createdAt))
.limit(8)
: Promise.resolve([]),
hasPermission(context, "care:read")
? database
.select()
.from(careTasks)
.where(and(eq(careTasks.organizationId, organizationId), eq(careTasks.elderId, elderId)))
.orderBy(desc(careTasks.scheduledAt))
.limit(12)
: Promise.resolve([]),
hasPermission(context, "alert:read")
? database
.select()
.from(alertTriggers)
.where(and(eq(alertTriggers.organizationId, organizationId), eq(alertTriggers.elderId, elderId)))
.orderBy(desc(alertTriggers.createdAt))
.limit(8)
: Promise.resolve([]),
hasPermission(context, "family:read")
? database
.select()
.from(familyFeedback)
.where(and(eq(familyFeedback.organizationId, organizationId), eq(familyFeedback.elderId, elderId)))
.orderBy(desc(familyFeedback.createdAt))
.limit(8)
: Promise.resolve([]),
hasPermission(context, "incident:read")
? database
.select()
.from(systemIncidents)
.where(eq(systemIncidents.organizationId, organizationId))
.orderBy(desc(systemIncidents.createdAt))
.limit(6)
: Promise.resolve([]),
]);
if (admissionRows.length > 0) {
if (hasPermission(context, "admission:read")) {
dataScopes.push("admission");
}
if (hasPermission(context, "facility:read")) {
dataScopes.push("facility");
}
appendSection(
sections,
"入住与床位",
admissionRows.map((row) => {
const citationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: row.admission.id,
title: "入住记录",
excerpt: `${row.admission.status},床位 ${row.roomCode ?? ""}-${row.bedCode ?? ""}`,
});
return `[${citationId}] 状态:${row.admission.status};房间/床位:${row.roomCode ?? "未知"}/${row.bedCode ?? "未知"};入住:${toIsoString(row.admission.admittedAt)};退住:${toIsoString(row.admission.dischargedAt)};备注:${row.admission.notes}`;
}),
);
}
if (hasPermission(context, "health:read")) {
dataScopes.push("health");
const profile = healthProfileRows[0];
appendSection(sections, "健康档案", [
profile
? `[${pushCitation(citations, {
sourceType: "resident_context",
sourceId: profile.id,
title: "健康档案",
excerpt: profile.medicalHistory || profile.medicationNotes || profile.emergencyNotes || "健康档案",
})}] 过敏:${profile.allergyNotes};病史:${profile.medicalHistory};用药:${profile.medicationNotes};照护限制:${profile.careRestrictions};紧急备注:${profile.emergencyNotes}`
: "暂无健康档案",
...vitalRows.map((vital) => {
const citationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: vital.id,
title: "生命体征",
excerpt: `${toIsoString(vital.recordedAt)} 心率 ${vital.heartRate ?? "-"} 血压 ${vital.systolicBp ?? "-"}/${vital.diastolicBp ?? "-"}`,
});
return `[${citationId}] ${toIsoString(vital.recordedAt)};来源:${vital.source};血压:${vital.systolicBp ?? "-"}/${vital.diastolicBp ?? "-"};心率:${vital.heartRate ?? "-"};体温:${vital.temperatureTenths ? vital.temperatureTenths / 10 : "-"};血氧:${vital.spo2 ?? "-"};备注:${vital.notes}`;
}),
...conditionRows.map((condition) => {
const citationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: condition.id,
title: "慢病记录",
excerpt: `${condition.name} ${condition.status}`,
});
return `[${citationId}] 慢病:${condition.name};状态:${condition.status};治疗:${condition.treatmentNotes};随访:${condition.followUpNotes}`;
}),
...reviewRows.map((review) => {
const citationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: review.id,
title: "健康异常复核",
excerpt: `${review.severity} ${review.title}`,
});
return `[${citationId}] 异常:${review.title};严重度:${review.severity};状态:${review.status};说明:${review.description};处理:${review.resolutionNotes}`;
}),
]);
}
if (hasPermission(context, "care:read")) {
dataScopes.push("care");
appendSection(
sections,
"护理任务",
careTaskRows.map((task) => {
const citationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: task.id,
title: "护理任务",
excerpt: `${task.priority} ${task.title}`,
});
return `[${citationId}] ${task.title};类型:${task.careType};优先级:${task.priority};状态:${task.status};计划:${toIsoString(task.scheduledAt)};执行备注:${task.executionNotes}`;
}),
);
}
if (hasPermission(context, "alert:read")) {
dataScopes.push("alert");
appendSection(
sections,
"预警触发",
alertRows.map((alert) => {
const citationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: alert.id,
title: "预警触发",
excerpt: `${alert.title} ${alert.status}`,
});
return `[${citationId}] ${alert.title};状态:${alert.status};来源:${alert.source};说明:${alert.description};处理:${alert.handlingNotes}`;
}),
);
}
if (hasPermission(context, "family:read")) {
dataScopes.push("family");
appendSection(
sections,
"家属反馈",
feedbackRows.map((feedback) => {
const citationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: feedback.id,
title: "家属反馈",
excerpt: `${feedback.feedbackType} ${feedback.status}`,
});
return `[${citationId}] 类型:${feedback.feedbackType};状态:${feedback.status};内容:${feedback.content};回复:${feedback.responseNotes}`;
}),
);
}
if (hasPermission(context, "incident:read")) {
dataScopes.push("incident");
appendSection(
sections,
"近期机构事件",
incidentRows.map((incident) => {
const citationId = pushCitation(citations, {
sourceType: "resident_context",
sourceId: incident.id,
title: "系统/应急事件",
excerpt: `${incident.severity} ${incident.title}`,
});
return `[${citationId}] ${incident.title};严重度:${incident.severity};状态:${incident.status};来源:${incident.source};说明:${incident.description}`;
}),
);
}
const uniqueScopes = [...new Set(dataScopes)];
return {
success: true,
context: {
organizationId,
elderId,
elderName: elder.name,
dataScopes: uniqueScopes,
sections,
citations,
promptContext: sections.join("\n\n"),
},
};
}

View File

@@ -0,0 +1,564 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OpenAIEmbeddings } from "@langchain/openai";
import {
createKnowledgeEntry,
listKnowledgeEntries,
retrieveKnowledge,
updateKnowledgeEntry,
} from "@/modules/ai/server/knowledge";
import type { KnowledgeEntryInput } from "@/modules/ai/types";
import type { AppDatabase } from "@/modules/core/server/db";
import { getDatabase } from "@/modules/core/server/db";
import type { AuthContext, Permission } from "@/modules/core/types";
const drizzleDsl = vi.hoisted(() => {
type TableName = "entries" | "chunks";
type ColumnRef = { table: TableName; key: string };
const entries = {
id: { table: "entries", key: "id" } as ColumnRef,
scope: { table: "entries", key: "scope" } as ColumnRef,
organizationId: { table: "entries", key: "organizationId" } as ColumnRef,
status: { table: "entries", key: "status" } as ColumnRef,
updatedAt: { table: "entries", key: "updatedAt" } as ColumnRef,
};
const chunks = {
id: { table: "chunks", key: "id" } as ColumnRef,
entryId: { table: "chunks", key: "entryId" } as ColumnRef,
scope: { table: "chunks", key: "scope" } as ColumnRef,
organizationId: { table: "chunks", key: "organizationId" } as ColumnRef,
sourceTitle: { table: "chunks", key: "sourceTitle" } as ColumnRef,
sourceCategory: { table: "chunks", key: "sourceCategory" } as ColumnRef,
content: { table: "chunks", key: "content" } as ColumnRef,
};
return { entries, chunks };
});
vi.mock("server-only", () => ({}));
vi.mock("@langchain/openai", () => ({
OpenAIEmbeddings: vi.fn(function MockOpenAIEmbeddings() {
return {};
}),
}));
vi.mock("@/modules/core/server/db", () => ({
getDatabase: vi.fn(),
}));
vi.mock("@/modules/core/server/schema", () => ({
aiKnowledgeEntries: drizzleDsl.entries,
aiKnowledgeChunks: drizzleDsl.chunks,
}));
vi.mock("drizzle-orm", () => {
type JoinedRow = {
entries?: Record<string, unknown>;
chunks?: Record<string, unknown>;
};
type ColumnRef = { table: "entries" | "chunks"; key: string };
type Predicate = (row: JoinedRow) => boolean;
function readColumn(row: JoinedRow, column: ColumnRef): unknown {
return row[column.table]?.[column.key];
}
return {
eq: (column: ColumnRef, expected: unknown): Predicate => (row) => readColumn(row, column) === expected,
isNull: (column: ColumnRef): Predicate => (row) => readColumn(row, column) === null || readColumn(row, column) === undefined,
and: (...predicates: Predicate[]): Predicate => (row) => predicates.every((predicate) => predicate(row)),
or: (...predicates: Predicate[]): Predicate => (row) => predicates.some((predicate) => predicate(row)),
desc: (column: ColumnRef) => ({ type: "desc", column }),
sql: () => ({ type: "distance" }),
};
});
type KnowledgeScope = "platform" | "organization";
type KnowledgeStatus = "enabled" | "disabled";
type KnowledgeRow = {
id: string;
scope: KnowledgeScope;
organizationId: string | null;
title: string;
category: string;
tags: string;
body: string;
status: KnowledgeStatus;
createdByAccountId: string | null;
updatedByAccountId: string | null;
createdAt: Date;
updatedAt: Date;
};
type KnowledgeChunkRow = {
id: string;
entryId: string;
organizationId: string | null;
scope: KnowledgeScope;
chunkIndex: number;
content: string;
sourceTitle: string;
sourceCategory: string;
createdAt: Date;
};
type JoinedRow = {
entries?: KnowledgeRow;
chunks?: KnowledgeChunkRow;
};
type Predicate = (row: JoinedRow) => boolean;
type SelectRow = KnowledgeRow | RetrievedChunkRow;
type RetrievedChunkRow = {
entryId: string;
chunkId: string;
title: string;
category: string;
content: string;
};
type TransactionWrite = { table: "entries" | "chunks"; values: unknown };
type KnowledgeDatabaseFake = {
entries: KnowledgeRow[];
chunks: KnowledgeChunkRow[];
transactionWrites: TransactionWrite[];
transaction: ReturnlessFunction;
select: ReturnlessFunction;
};
type ReturnlessFunction = (...args: unknown[]) => unknown;
const organization: NonNullable<AuthContext["organization"]> = {
id: "org-1",
name: "TeaCare Home",
slug: "teacare",
status: "active",
registrationEnabled: true,
oidcEnabled: false,
oidcIssuerUrl: "",
oidcClientId: "",
oidcHasClientSecret: false,
oidcScopes: "",
oidcRedirectUri: "",
oidcAvatarClaim: "",
oidcAutoProvision: false,
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const account: AuthContext["account"] = {
id: "account-1",
name: "Knowledge Admin",
email: "knowledge@example.com",
avatarUrl: "",
role: "org_admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const baseInput: KnowledgeEntryInput = {
scope: "organization",
organizationId: "org-1",
title: "Fall prevention",
category: "Safety",
tags: "fall,night",
body: "Keep walkways clear and increase night rounds after wandering events.",
status: "enabled",
};
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
function createAuthContext(input: { permissions?: Permission[]; activeOrganization?: boolean } = {}): AuthContext {
const activeOrganization = input.activeOrganization ?? true;
const permissions = input.permissions ?? ["knowledge:read", "knowledge:manage"];
return {
account,
organization: activeOrganization ? organizationContext : undefined,
organizations: [
{
id: organizationContext.id,
name: organizationContext.name,
slug: organizationContext.slug,
status: organizationContext.status,
registrationEnabled: organizationContext.registrationEnabled,
oidcEnabled: organizationContext.oidcEnabled,
roleLabel: "Admin",
isActive: activeOrganization,
},
],
membership: activeOrganization
? {
id: "membership-1",
accountId: account.id,
organizationId: organizationContext.id,
roleId: "role-1",
roleKey: "org_admin",
roleLabel: "Admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
}
: undefined,
permissions,
session: {
id: "session-1",
accountId: account.id,
activeOrganizationId: activeOrganization ? organizationContext.id : undefined,
createdAt: "2026-07-02T00:00:00.000Z",
expiresAt: "2026-07-09T00:00:00.000Z",
},
};
}
function createEntry(overrides: Partial<KnowledgeRow> = {}): KnowledgeRow {
return {
id: "entry-org-1",
scope: "organization",
organizationId: "org-1",
title: "Org fall prevention",
category: "Safety",
tags: "fall",
body: "Organization-specific fall prevention guidance.",
status: "enabled",
createdByAccountId: "account-1",
updatedByAccountId: "account-1",
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-02T00:00:00.000Z"),
...overrides,
};
}
function createChunk(entry: KnowledgeRow, overrides: Partial<KnowledgeChunkRow> = {}): KnowledgeChunkRow {
return {
id: `${entry.id}-chunk-1`,
entryId: entry.id,
organizationId: entry.organizationId,
scope: entry.scope,
chunkIndex: 0,
content: entry.body,
sourceTitle: entry.title,
sourceCategory: entry.category,
createdAt: new Date("2026-07-02T00:00:00.000Z"),
...overrides,
};
}
class SelectBuilder implements PromiseLike<SelectRow[]> {
private table: "entries" | "chunks" = "entries";
private predicate: Predicate = () => true;
constructor(
private readonly entries: KnowledgeRow[],
private readonly chunks: KnowledgeChunkRow[],
) {}
from(table: unknown): SelectBuilder {
this.table = table === drizzleDsl.chunks ? "chunks" : "entries";
return this;
}
innerJoin(): SelectBuilder {
return this;
}
where(predicate: Predicate): SelectBuilder {
this.predicate = predicate;
return this;
}
orderBy(): SelectBuilder {
return this;
}
limit(count: number): Promise<SelectRow[]> {
return Promise.resolve(this.execute().slice(0, count));
}
then<TResult1 = SelectRow[], TResult2 = never>(
onfulfilled?: ((value: SelectRow[]) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return Promise.resolve(this.execute()).then(onfulfilled, onrejected);
}
private execute(): SelectRow[] {
if (this.table === "entries") {
return this.entries
.filter((entry) => this.predicate({ entries: entry }))
.sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime());
}
const rows: RetrievedChunkRow[] = [];
for (const chunk of this.chunks) {
const entry = this.entries.find((candidate) => candidate.id === chunk.entryId);
if (entry && this.predicate({ entries: entry, chunks: chunk })) {
rows.push({
entryId: chunk.entryId,
chunkId: chunk.id,
title: chunk.sourceTitle,
category: chunk.sourceCategory,
content: chunk.content,
});
}
}
return rows;
}
}
function createDatabaseFake(input: { entries?: KnowledgeRow[]; chunks?: KnowledgeChunkRow[] } = {}): KnowledgeDatabaseFake {
const entries = input.entries ?? [];
const chunks = input.chunks ?? [];
const transactionWrites: TransactionWrite[] = [];
const tableName = (table: unknown): "entries" | "chunks" => (table === drizzleDsl.chunks ? "chunks" : "entries");
const readRecord = (value: unknown): Record<string, unknown> => {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
};
const readString = (value: unknown, fallback: string): string => (typeof value === "string" ? value : fallback);
const readOrganizationId = (value: unknown, fallback: string | null): string | null => {
if (typeof value === "string") {
return value;
}
if (value === null) {
return null;
}
return fallback;
};
const buildEntryRow = (values: unknown, fallback?: KnowledgeRow): KnowledgeRow => {
const record = readRecord(values);
return createEntry({
id: fallback?.id ?? `entry-created-${entries.length + 1}`,
scope: record.scope === "platform" ? "platform" : "organization",
organizationId: readOrganizationId(record.organizationId, fallback?.organizationId ?? "org-1"),
title: readString(record.title, fallback?.title ?? "Created guidance"),
category: readString(record.category, fallback?.category ?? "Safety"),
tags: readString(record.tags, fallback?.tags ?? "fall"),
body: readString(record.body, fallback?.body ?? "Created body"),
status: record.status === "disabled" ? "disabled" : "enabled",
createdByAccountId: readString(record.createdByAccountId, fallback?.createdByAccountId ?? "account-1"),
updatedByAccountId: readString(record.updatedByAccountId, fallback?.updatedByAccountId ?? "account-1"),
createdAt: fallback?.createdAt ?? new Date("2026-07-02T00:00:00.000Z"),
updatedAt: new Date("2026-07-02T00:00:00.000Z"),
});
};
type TransactionFake = {
insert: ReturnlessFunction;
update: ReturnlessFunction;
delete: ReturnlessFunction;
};
const transactionFake: TransactionFake = {
insert: vi.fn((table: unknown) => ({
values: vi.fn((values: unknown) => {
transactionWrites.push({ table: tableName(table), values });
if (table === drizzleDsl.entries) {
const row = buildEntryRow(values);
entries.push(row);
return { returning: vi.fn(async () => [row]) };
}
return Promise.resolve();
}),
})),
update: vi.fn((table: unknown) => ({
set: vi.fn((values: unknown) => ({
where: vi.fn(() => ({
returning: vi.fn(async () => {
const fallback = entries[0] ?? createEntry();
const row = buildEntryRow(values, fallback);
transactionWrites.push({ table: tableName(table), values });
const existingIndex = entries.findIndex((entry) => entry.id === row.id);
if (existingIndex >= 0) {
entries[existingIndex] = row;
} else {
entries.push(row);
}
return [row];
}),
})),
})),
})),
delete: vi.fn((table: unknown) => ({
where: vi.fn(() => {
transactionWrites.push({ table: tableName(table), values: { delete: true } });
}),
})),
};
const runTransaction: ReturnlessFunction = async (callback: unknown) => {
if (typeof callback !== "function") {
return null;
}
return (callback as (transaction: TransactionFake) => Promise<KnowledgeRow | null>)(transactionFake);
};
return {
entries,
chunks,
transactionWrites,
transaction: vi.fn(runTransaction),
select: vi.fn(() => new SelectBuilder(entries, chunks)),
};
}
function useDatabase(fake: KnowledgeDatabaseFake): void {
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
}
beforeEach(() => {
vi.clearAllMocks();
useDatabase(createDatabaseFake());
});
describe("AI knowledge service", () => {
it("lists platform and active-organization entries while excluding other organizations", async () => {
const platformEntry = createEntry({
id: "entry-platform",
scope: "platform",
organizationId: null,
title: "Platform safety standard",
updatedAt: new Date("2026-07-03T00:00:00.000Z"),
});
const orgEntry = createEntry({ id: "entry-org-1", title: "Org safety note" });
const otherOrgEntry = createEntry({ id: "entry-org-2", organizationId: "org-2", title: "Other org note" });
useDatabase(createDatabaseFake({ entries: [orgEntry, otherOrgEntry, platformEntry] }));
const entries = await listKnowledgeEntries(createAuthContext());
expect(entries.map((entry) => entry.id)).toEqual(["entry-platform", "entry-org-1"]);
expect(entries).toEqual([
expect.objectContaining({ id: "entry-platform", scope: "platform", organizationId: undefined }),
expect.objectContaining({ id: "entry-org-1", scope: "organization", organizationId: "org-1" }),
]);
});
it("retrieves only enabled platform and active-organization chunks", async () => {
const platformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null, title: "Platform standard" });
const orgEntry = createEntry({ id: "entry-org-1", title: "Org guidance" });
const disabledEntry = createEntry({ id: "entry-disabled", status: "disabled", title: "Disabled guidance" });
const otherOrgEntry = createEntry({ id: "entry-org-2", organizationId: "org-2", title: "Other org guidance" });
const database = createDatabaseFake({
entries: [platformEntry, orgEntry, disabledEntry, otherOrgEntry],
chunks: [
createChunk(platformEntry),
createChunk(orgEntry),
createChunk(disabledEntry),
createChunk(otherOrgEntry, { organizationId: "org-2", scope: "organization" }),
],
});
useDatabase(database);
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 10 });
expect(result).toEqual({
success: true,
data: {
results: [
expect.objectContaining({ entryId: "entry-platform", title: "Platform standard" }),
expect.objectContaining({ entryId: "entry-org-1", title: "Org guidance" }),
],
},
});
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("ranks matching chunks by lexical overlap and drops nonmatching chunks after scope filtering", async () => {
const weakEntry = createEntry({ id: "entry-weak", title: "Weak guidance", body: "General fall prevention guidance." });
const strongEntry = createEntry({ id: "entry-strong", title: "Strong guidance", body: "Night fall risk guidance for wandering residents." });
const unrelatedEntry = createEntry({ id: "entry-unrelated", title: "Nutrition guidance", body: "Hydration and meals." });
const disabledEntry = createEntry({ id: "entry-disabled", status: "disabled", title: "Disabled night fall risk" });
const otherOrgEntry = createEntry({ id: "entry-other-org", organizationId: "org-2", title: "Other org night fall risk" });
useDatabase(createDatabaseFake({
entries: [weakEntry, strongEntry, unrelatedEntry, disabledEntry, otherOrgEntry],
chunks: [
createChunk(weakEntry),
createChunk(strongEntry),
createChunk(unrelatedEntry),
createChunk(disabledEntry),
createChunk(otherOrgEntry, { organizationId: "org-2", scope: "organization" }),
],
}));
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 5 });
expect(result).toEqual({
success: true,
data: {
results: [
expect.objectContaining({ entryId: "entry-strong", score: 1 }),
expect.objectContaining({ entryId: "entry-weak", score: 1 / 3 }),
],
},
});
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("requires platform management permission before mutating platform knowledge", async () => {
const existingPlatformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null });
useDatabase(createDatabaseFake({ entries: [existingPlatformEntry] }));
const result = await updateKnowledgeEntry(
createAuthContext({ permissions: ["knowledge:manage"] }),
"entry-platform",
baseInput,
);
expect(result).toEqual({ success: false, reason: "只有平台管理员可以维护平台知识", status: 403 });
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("requires an active organization before mutating organization knowledge", async () => {
const result = await createKnowledgeEntry(
createAuthContext({ activeOrganization: false, permissions: ["knowledge:manage"] }),
baseInput,
);
expect(result).toEqual({ success: false, reason: "请选择机构后维护知识库", status: 400 });
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("rejects attempts to mutate another organization's knowledge", async () => {
const result = await createKnowledgeEntry(createAuthContext(), {
...baseInput,
organizationId: "org-2",
});
expect(result).toEqual({ success: false, reason: "不能维护其他机构的知识库", status: 403 });
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("writes lexical chunks with empty embedding arrays when creating and updating knowledge", async () => {
const existingEntry = createEntry({ id: "entry-existing", title: "Existing guidance" });
const database = createDatabaseFake({ entries: [existingEntry] });
useDatabase(database);
const created = await createKnowledgeEntry(createAuthContext(), baseInput);
expect(created.success).toBe(true);
const createdChunkWrite = database.transactionWrites.find((write) => write.table === "chunks" && Array.isArray(write.values));
expect(createdChunkWrite?.values).toEqual([
expect.objectContaining({
content: expect.stringContaining("Fall prevention"),
embedding: [],
}),
]);
database.transactionWrites.length = 0;
const updated = await updateKnowledgeEntry(createAuthContext(), "entry-existing", baseInput);
expect(updated.success).toBe(true);
const updatedChunkWrite = database.transactionWrites.find((write) => write.table === "chunks" && Array.isArray(write.values));
expect(updatedChunkWrite?.values).toEqual([
expect.objectContaining({
content: expect.stringContaining("Fall prevention"),
embedding: [],
}),
]);
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,358 @@
import "server-only";
import { and, desc, eq, isNull, or } from "drizzle-orm";
import type {
AiKnowledgeScope,
KnowledgeEntry,
KnowledgeEntryInput,
KnowledgeRetrievalResult,
} from "@/modules/ai/types";
import { getDatabase } from "@/modules/core/server/db";
import { aiKnowledgeChunks, aiKnowledgeEntries } from "@/modules/core/server/schema";
import type { AuthContext } from "@/modules/core/types";
type KnowledgeRow = typeof aiKnowledgeEntries.$inferSelect;
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
const CHUNK_TARGET_SIZE = 900;
const CHUNK_OVERLAP_SIZE = 160;
const DEFAULT_RETRIEVAL_LIMIT = 6;
function toIsoString(value: Date): string {
return value.toISOString();
}
function toKnowledgeEntry(row: KnowledgeRow): KnowledgeEntry {
return {
id: row.id,
scope: row.scope,
organizationId: row.organizationId ?? undefined,
title: row.title,
category: row.category,
tags: row.tags,
body: row.body,
status: row.status,
createdAt: toIsoString(row.createdAt),
updatedAt: toIsoString(row.updatedAt),
};
}
function normalizeSearchText(value: string): string[] {
const tokens = new Set<string>();
const normalizedTokens = value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
for (const token of normalizedTokens) {
if (token.length >= 2) {
tokens.add(token);
}
const cjkSequences = token.match(/[\u3400-\u9fff]+/gu) ?? [];
for (const sequence of cjkSequences) {
for (let index = 0; index < sequence.length - 1; index += 1) {
tokens.add(sequence.slice(index, index + 2));
}
}
}
return Array.from(tokens);
}
function chunkKnowledgeText(input: KnowledgeEntryInput): string[] {
const prefix = [input.title, input.category, input.tags].filter(Boolean).join("\n");
const text = `${prefix}\n\n${input.body}`.trim();
if (text.length <= CHUNK_TARGET_SIZE) {
return [text];
}
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + CHUNK_TARGET_SIZE, text.length);
chunks.push(text.slice(start, end).trim());
if (end >= text.length) {
break;
}
start = Math.max(end - CHUNK_OVERLAP_SIZE, start + 1);
}
return chunks.filter(Boolean);
}
function ensureWritableScope(context: AuthContext, input: KnowledgeEntryInput): ServiceResult<{ organizationId: string | null }> {
if (input.scope === "platform") {
if (!context.permissions.includes("platform:manage")) {
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
}
return { success: true, data: { organizationId: null } };
}
const organizationId = context.organization?.id;
if (!organizationId) {
return { success: false, reason: "请选择机构后维护知识库", status: 400 };
}
if (input.organizationId && input.organizationId !== organizationId) {
return { success: false, reason: "不能维护其他机构的知识库", status: 403 };
}
return { success: true, data: { organizationId } };
}
function getVisibleKnowledgeWhere(organizationId?: string) {
const platformFilter = and(eq(aiKnowledgeEntries.scope, "platform"), isNull(aiKnowledgeEntries.organizationId));
if (!organizationId) {
return platformFilter;
}
return or(
platformFilter,
and(eq(aiKnowledgeEntries.scope, "organization"), eq(aiKnowledgeEntries.organizationId, organizationId)),
);
}
function getEnabledChunkWhere(organizationId: string) {
return and(
eq(aiKnowledgeEntries.status, "enabled"),
or(
and(eq(aiKnowledgeChunks.scope, "platform"), isNull(aiKnowledgeChunks.organizationId)),
and(eq(aiKnowledgeChunks.scope, "organization"), eq(aiKnowledgeChunks.organizationId, organizationId)),
),
);
}
function buildKnowledgeChunks(input: KnowledgeEntryInput): string[] {
return chunkKnowledgeText(input);
}
export async function listKnowledgeEntries(context: AuthContext): Promise<KnowledgeEntry[]> {
const database = getDatabase();
const rows = await database
.select()
.from(aiKnowledgeEntries)
.where(getVisibleKnowledgeWhere(context.organization?.id))
.orderBy(desc(aiKnowledgeEntries.updatedAt));
return rows.map(toKnowledgeEntry);
}
export async function createKnowledgeEntry(
context: AuthContext,
input: KnowledgeEntryInput,
): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
const scope = ensureWritableScope(context, input);
if (!scope.success) {
return scope;
}
const chunks = buildKnowledgeChunks(input);
const database = getDatabase();
const row = await database.transaction(async (transaction) => {
const rows = await transaction
.insert(aiKnowledgeEntries)
.values({
scope: input.scope,
organizationId: scope.data.organizationId,
title: input.title,
category: input.category,
tags: input.tags,
body: input.body,
status: input.status,
createdByAccountId: context.account.id,
updatedByAccountId: context.account.id,
updatedAt: new Date(),
})
.returning();
const entry = rows[0];
if (!entry) {
return null;
}
if (chunks.length > 0) {
await transaction.insert(aiKnowledgeChunks).values(
chunks.map((content, index) => ({
entryId: entry.id,
organizationId: entry.organizationId,
scope: entry.scope,
chunkIndex: index,
content,
embedding: [],
sourceTitle: entry.title,
sourceCategory: entry.category,
})),
);
}
return entry;
});
if (!row) {
return { success: false, reason: "知识库创建失败", status: 500 };
}
return { success: true, data: { entry: toKnowledgeEntry(row) } };
}
async function updateEntryWithChunks(
id: string,
context: AuthContext,
input: KnowledgeEntryInput,
organizationId: string | null,
): Promise<KnowledgeRow | null> {
const chunks = buildKnowledgeChunks(input);
const database = getDatabase();
return database.transaction(async (transaction) => {
const rows = await transaction
.update(aiKnowledgeEntries)
.set({
scope: input.scope,
organizationId,
title: input.title,
category: input.category,
tags: input.tags,
body: input.body,
status: input.status,
updatedByAccountId: context.account.id,
updatedAt: new Date(),
})
.where(eq(aiKnowledgeEntries.id, id))
.returning();
const row = rows[0];
if (!row) {
return null;
}
await transaction.delete(aiKnowledgeChunks).where(eq(aiKnowledgeChunks.entryId, row.id));
if (chunks.length > 0) {
await transaction.insert(aiKnowledgeChunks).values(
chunks.map((content, index) => ({
entryId: row.id,
organizationId: row.organizationId,
scope: row.scope,
chunkIndex: index,
content,
embedding: [],
sourceTitle: row.title,
sourceCategory: row.category,
})),
);
}
return row;
});
}
export async function updateKnowledgeEntry(
context: AuthContext,
id: string,
input: KnowledgeEntryInput,
): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
const scope = ensureWritableScope(context, input);
if (!scope.success) {
return scope;
}
const database = getDatabase();
const existingRows = await database
.select()
.from(aiKnowledgeEntries)
.where(and(eq(aiKnowledgeEntries.id, id), getVisibleKnowledgeWhere(context.organization?.id)))
.limit(1);
const existing = existingRows[0];
if (!existing) {
return { success: false, reason: "知识库条目不存在", status: 404 };
}
if (existing.scope === "platform" && !context.permissions.includes("platform:manage")) {
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
}
let row: KnowledgeRow | null;
try {
row = await updateEntryWithChunks(id, context, input, scope.data.organizationId);
} catch {
return { success: false, reason: "知识库更新失败", status: 500 };
}
if (!row) {
return { success: false, reason: "知识库更新失败", status: 500 };
}
return { success: true, data: { entry: toKnowledgeEntry(row) } };
}
export async function deleteKnowledgeEntry(context: AuthContext, id: string): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
const database = getDatabase();
const existingRows = await database
.select()
.from(aiKnowledgeEntries)
.where(and(eq(aiKnowledgeEntries.id, id), getVisibleKnowledgeWhere(context.organization?.id)))
.limit(1);
const existing = existingRows[0];
if (!existing) {
return { success: false, reason: "知识库条目不存在", status: 404 };
}
if (existing.scope === "platform" && !context.permissions.includes("platform:manage")) {
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
}
const rows = await database.delete(aiKnowledgeEntries).where(eq(aiKnowledgeEntries.id, id)).returning();
const row = rows[0];
if (!row) {
return { success: false, reason: "知识库删除失败", status: 500 };
}
return { success: true, data: { entry: toKnowledgeEntry(row) } };
}
function scoreKnowledgeChunk(
chunk: Pick<KnowledgeRetrievalResult, "title" | "category" | "content">,
queryTokens: readonly string[],
): number {
if (queryTokens.length === 0) {
return 0;
}
const chunkTokens = new Set(normalizeSearchText(`${chunk.title}\n${chunk.category}\n${chunk.content}`));
let matchedTokens = 0;
for (const token of queryTokens) {
if (chunkTokens.has(token)) {
matchedTokens += 1;
}
}
return matchedTokens / queryTokens.length;
}
export async function retrieveKnowledge(
organizationId: string,
query: string,
options?: { limit?: number },
): Promise<ServiceResult<{ results: KnowledgeRetrievalResult[] }>> {
const queryTokens = normalizeSearchText(query);
const database = getDatabase();
const rows = await database
.select({
entryId: aiKnowledgeChunks.entryId,
chunkId: aiKnowledgeChunks.id,
title: aiKnowledgeChunks.sourceTitle,
category: aiKnowledgeChunks.sourceCategory,
content: aiKnowledgeChunks.content,
})
.from(aiKnowledgeChunks)
.innerJoin(aiKnowledgeEntries, eq(aiKnowledgeEntries.id, aiKnowledgeChunks.entryId))
.where(getEnabledChunkWhere(organizationId));
return {
success: true,
data: {
results: rows
.map((row) => ({
entryId: row.entryId,
chunkId: row.chunkId,
title: row.title,
category: row.category,
content: row.content,
score: scoreKnowledgeChunk(row, queryTokens),
}))
.filter((row) => row.score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, options?.limit ?? DEFAULT_RETRIEVAL_LIMIT),
},
};
}
export function normalizeKnowledgeScope(scope: AiKnowledgeScope, organizationId?: string): string {
return scope === "platform" ? "platform" : `organization:${organizationId ?? ""}`;
}

175
modules/ai/types.test.ts Normal file
View File

@@ -0,0 +1,175 @@
import { describe, expect, it } from "vitest";
import type { AiCitation, ElderAiAnalysisOutput, ElderAiFinding, ElderAiRecommendation, KnowledgeEntryInput } from "@/modules/ai/types";
import {
parseDataScopes,
validateElderAiAnalysisOutput,
validateKnowledgeEntryInput,
} from "@/modules/ai/types";
const validKnowledgeInput: KnowledgeEntryInput = {
scope: "organization",
organizationId: "org-1",
title: "Fall prevention guidance",
category: "Safety",
tags: "fall,night",
body: "Residents with night wandering need bed exit checks and clear walkways.",
status: "enabled",
};
const validFinding: ElderAiFinding = {
category: "fall-risk",
severity: "warning",
evidence: "Night wandering and prior fall history are present.",
citationIds: ["resident-1"],
};
const validRecommendation: ElderAiRecommendation = {
title: "Increase night checks",
priority: "high",
rationale: "Additional observation reduces missed night wandering events.",
suggestedNextStep: "Add a night-shift round note for the next care review.",
citationIds: ["resident-1"],
};
const validCitation: AiCitation = {
id: "resident-1",
sourceType: "resident_context",
sourceId: "elder-1",
title: "Resident profile",
excerpt: "Night wandering and prior fall history.",
};
const validAnalysisOutput: ElderAiAnalysisOutput = {
overallRiskLevel: "medium",
summary: "Resident has elevated fall risk at night.",
keyFindings: [validFinding],
recommendations: [validRecommendation],
dataGaps: ["Recent gait assessment is unavailable."],
citations: [validCitation],
confidence: 0.72,
modelSummary: {
provider: "openai-compatible",
chatModel: "gpt-test",
knowledgeRetrieval: "keyword",
},
};
describe("AI validators", () => {
describe("validateKnowledgeEntryInput", () => {
it("accepts a scoped entry and trims user-authored text fields", () => {
const result = validateKnowledgeEntryInput({
...validKnowledgeInput,
title: " Fall prevention guidance ",
category: " Safety ",
tags: " fall,night ",
body: " Residents need clear walkways. ",
});
expect(result).toEqual({
success: true,
input: {
...validKnowledgeInput,
title: "Fall prevention guidance",
category: "Safety",
tags: "fall,night",
body: "Residents need clear walkways.",
},
});
});
it.each([
{ name: "non-object payload", value: null, reason: "请求数据格式无效" },
{ name: "unknown scope", value: { ...validKnowledgeInput, scope: "facility" }, reason: "知识库范围无效" },
{ name: "unknown status", value: { ...validKnowledgeInput, status: "archived" }, reason: "知识库状态无效" },
{ name: "blank title", value: { ...validKnowledgeInput, title: " " }, reason: "知识标题不能为空" },
{ name: "blank body", value: { ...validKnowledgeInput, body: "\n\t" }, reason: "知识内容不能为空" },
])("rejects $name", ({ value, reason }) => {
expect(validateKnowledgeEntryInput(value)).toEqual({ success: false, reason });
});
});
describe("validateElderAiAnalysisOutput", () => {
it("accepts valid model output, coerces numeric confidence strings, and clamps the public score", () => {
const result = validateElderAiAnalysisOutput({
...validAnalysisOutput,
confidence: "1.25",
});
expect(result).toEqual({
...validAnalysisOutput,
confidence: 1,
});
});
it.each([
{
name: "unknown risk level",
value: { ...validAnalysisOutput, overallRiskLevel: "urgent" },
},
{
name: "finding with invalid severity",
value: {
...validAnalysisOutput,
keyFindings: [{ ...validFinding, severity: "debug" }],
},
},
{
name: "recommendation with invalid priority",
value: {
...validAnalysisOutput,
recommendations: [{ ...validRecommendation, priority: "soon" }],
},
},
{
name: "mixed citation id array",
value: {
...validAnalysisOutput,
keyFindings: [{ ...validFinding, citationIds: ["resident-1", 42] }],
},
},
{
name: "invalid citation source",
value: {
...validAnalysisOutput,
citations: [{ ...validCitation, sourceType: "web" }],
},
},
{
name: "unexpected model provider",
value: {
...validAnalysisOutput,
modelSummary: { ...validAnalysisOutput.modelSummary, provider: "anthropic" },
},
},
{
name: "unexpected knowledge retrieval mode",
value: {
...validAnalysisOutput,
modelSummary: { ...validAnalysisOutput.modelSummary, knowledgeRetrieval: "embedding" },
},
},
{
name: "non-finite confidence",
value: { ...validAnalysisOutput, confidence: "not-a-number" },
},
])("rejects $name", ({ value }) => {
expect(validateElderAiAnalysisOutput(value)).toBeNull();
});
});
describe("parseDataScopes", () => {
it("keeps only recognized scope strings in caller order", () => {
expect(parseDataScopes(["elder", "health", "unknown", 7, "knowledge", "elder"])).toEqual([
"elder",
"health",
"knowledge",
"elder",
]);
});
it.each([undefined, null, "elder", { scopes: ["elder"] }])("returns an empty list for non-array input %#", (value) => {
expect(parseDataScopes(value)).toEqual([]);
});
});
});

336
modules/ai/types.ts Normal file
View File

@@ -0,0 +1,336 @@
import type { Permission } from "@/modules/core/types";
export const AI_KNOWLEDGE_SCOPES = ["platform", "organization"] as const;
export type AiKnowledgeScope = (typeof AI_KNOWLEDGE_SCOPES)[number];
export const AI_KNOWLEDGE_STATUSES = ["enabled", "disabled"] as const;
export type AiKnowledgeStatus = (typeof AI_KNOWLEDGE_STATUSES)[number];
export const ELDER_AI_ANALYSIS_STATUSES = ["completed", "failed"] as const;
export type ElderAiAnalysisStatus = (typeof ELDER_AI_ANALYSIS_STATUSES)[number];
export const ELDER_AI_DATA_SCOPES = [
"elder",
"health",
"care",
"family",
"admission",
"facility",
"alert",
"incident",
"knowledge",
] as const;
export type ElderAiDataScope = (typeof ELDER_AI_DATA_SCOPES)[number];
export const ELDER_AI_RISK_LEVELS = ["low", "medium", "high", "critical", "unknown"] as const;
export type ElderAiRiskLevel = (typeof ELDER_AI_RISK_LEVELS)[number];
export const ELDER_AI_SEVERITIES = ["info", "warning", "critical"] as const;
export type ElderAiSeverity = (typeof ELDER_AI_SEVERITIES)[number];
export const ELDER_AI_RECOMMENDATION_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
export type ElderAiRecommendationPriority = (typeof ELDER_AI_RECOMMENDATION_PRIORITIES)[number];
export const AI_ERROR_CATEGORIES = [
"missing_config",
"provider_error",
"timeout",
"schema_validation_failed",
"retrieval_failed",
"unknown",
] as const;
export type AiErrorCategory = (typeof AI_ERROR_CATEGORIES)[number];
export type AiCitation = {
id: string;
sourceType: "resident_context" | "knowledge";
sourceId: string;
title: string;
excerpt: string;
};
export type ElderAiFinding = {
category: string;
severity: ElderAiSeverity;
evidence: string;
citationIds: string[];
};
export type ElderAiRecommendation = {
title: string;
priority: ElderAiRecommendationPriority;
rationale: string;
suggestedNextStep: string;
citationIds: string[];
};
export type ElderAiModelSummary = {
provider: "openai-compatible";
chatModel: string;
knowledgeRetrieval: "keyword";
};
export type ElderAiAnalysisOutput = {
overallRiskLevel: ElderAiRiskLevel;
summary: string;
keyFindings: ElderAiFinding[];
recommendations: ElderAiRecommendation[];
dataGaps: string[];
citations: AiCitation[];
confidence: number;
modelSummary: ElderAiModelSummary;
};
export type ElderAiAnalysisHistoryItem = {
id: string;
elderId: string;
status: ElderAiAnalysisStatus;
dataScopes: ElderAiDataScope[];
createdAt: string;
restricted: boolean;
result?: ElderAiAnalysisOutput;
errorCategory?: AiErrorCategory;
errorReason?: string;
};
export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & {
elderName: string;
};
export type KnowledgeEntryInput = {
scope: AiKnowledgeScope;
organizationId?: string;
title: string;
category: string;
tags: string;
body: string;
status: AiKnowledgeStatus;
};
export type KnowledgeEntry = KnowledgeEntryInput & {
id: string;
createdAt: string;
updatedAt: string;
};
export type KnowledgeRetrievalResult = {
entryId: string;
chunkId: string;
title: string;
category: string;
content: string;
score: number;
};
const DATA_SCOPE_PERMISSION_MAP: Record<Exclude<ElderAiDataScope, "elder" | "knowledge">, Permission> = {
health: "health:read",
care: "care:read",
family: "family:read",
admission: "admission:read",
facility: "facility:read",
alert: "alert:read",
incident: "incident:read",
};
export function isElderAiDataScope(value: string): value is ElderAiDataScope {
return (ELDER_AI_DATA_SCOPES as readonly string[]).includes(value);
}
export function getPermissionForDataScope(scope: ElderAiDataScope): Permission {
if (scope === "elder") {
return "elder:read";
}
if (scope === "knowledge") {
return "knowledge:read";
}
return DATA_SCOPE_PERMISSION_MAP[scope];
}
export function canViewAnalysisScopes(scopes: readonly ElderAiDataScope[], permissions: readonly Permission[]): boolean {
return scopes.every((scope) => permissions.includes(getPermissionForDataScope(scope)));
}
export function parseDataScopes(value: unknown): ElderAiDataScope[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is ElderAiDataScope => typeof item === "string" && isElderAiDataScope(item));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) {
return null;
}
const items = value.filter((item): item is string => typeof item === "string");
return items.length === value.length ? items : null;
}
function validateCitation(value: unknown): AiCitation | null {
if (!isRecord(value)) {
return null;
}
const sourceType = value.sourceType;
if (sourceType !== "resident_context" && sourceType !== "knowledge") {
return null;
}
if (
typeof value.id !== "string" ||
typeof value.sourceId !== "string" ||
typeof value.title !== "string" ||
typeof value.excerpt !== "string"
) {
return null;
}
return {
id: value.id,
sourceType,
sourceId: value.sourceId,
title: value.title,
excerpt: value.excerpt,
};
}
function validateFinding(value: unknown): ElderAiFinding | null {
if (!isRecord(value)) {
return null;
}
const citationIds = readStringArray(value.citationIds);
if (
typeof value.category !== "string" ||
typeof value.evidence !== "string" ||
!ELDER_AI_SEVERITIES.includes(value.severity as ElderAiSeverity) ||
!citationIds
) {
return null;
}
return {
category: value.category,
severity: value.severity as ElderAiSeverity,
evidence: value.evidence,
citationIds,
};
}
function validateRecommendation(value: unknown): ElderAiRecommendation | null {
if (!isRecord(value)) {
return null;
}
const citationIds = readStringArray(value.citationIds);
if (
typeof value.title !== "string" ||
typeof value.rationale !== "string" ||
typeof value.suggestedNextStep !== "string" ||
!ELDER_AI_RECOMMENDATION_PRIORITIES.includes(value.priority as ElderAiRecommendationPriority) ||
!citationIds
) {
return null;
}
return {
title: value.title,
priority: value.priority as ElderAiRecommendationPriority,
rationale: value.rationale,
suggestedNextStep: value.suggestedNextStep,
citationIds,
};
}
function validateModelSummary(value: unknown): ElderAiModelSummary | null {
if (!isRecord(value)) {
return null;
}
if (
value.provider !== "openai-compatible" ||
typeof value.chatModel !== "string" ||
value.knowledgeRetrieval !== "keyword"
) {
return null;
}
return {
provider: "openai-compatible",
chatModel: value.chatModel,
knowledgeRetrieval: "keyword",
};
}
export function validateElderAiAnalysisOutput(value: unknown): ElderAiAnalysisOutput | null {
if (!isRecord(value)) {
return null;
}
const confidenceValue = typeof value.confidence === "string" ? Number(value.confidence) : value.confidence;
const keyFindings = Array.isArray(value.keyFindings) ? value.keyFindings.map(validateFinding) : null;
const recommendations = Array.isArray(value.recommendations) ? value.recommendations.map(validateRecommendation) : null;
const citations = Array.isArray(value.citations) ? value.citations.map(validateCitation) : null;
const dataGaps = readStringArray(value.dataGaps);
const modelSummary = validateModelSummary(value.modelSummary);
if (
!ELDER_AI_RISK_LEVELS.includes(value.overallRiskLevel as ElderAiRiskLevel) ||
typeof value.summary !== "string" ||
typeof confidenceValue !== "number" ||
!Number.isFinite(confidenceValue) ||
!keyFindings ||
keyFindings.includes(null) ||
!recommendations ||
recommendations.includes(null) ||
!dataGaps ||
!citations ||
citations.includes(null) ||
!modelSummary
) {
return null;
}
return {
overallRiskLevel: value.overallRiskLevel as ElderAiRiskLevel,
summary: value.summary,
keyFindings: keyFindings.filter((item): item is ElderAiFinding => Boolean(item)),
recommendations: recommendations.filter((item): item is ElderAiRecommendation => Boolean(item)),
dataGaps,
citations: citations.filter((item): item is AiCitation => Boolean(item)),
confidence: Math.max(0, Math.min(1, confidenceValue)),
modelSummary,
};
}
export function validateKnowledgeEntryInput(value: unknown): { success: true; input: KnowledgeEntryInput } | { success: false; reason: string } {
if (!value || typeof value !== "object") {
return { success: false, reason: "请求数据格式无效" };
}
const record = value as Record<string, unknown>;
const scope = typeof record.scope === "string" ? record.scope : "";
const title = typeof record.title === "string" ? record.title.trim() : "";
const category = typeof record.category === "string" ? record.category.trim() : "";
const tags = typeof record.tags === "string" ? record.tags.trim() : "";
const body = typeof record.body === "string" ? record.body.trim() : "";
const status = typeof record.status === "string" ? record.status : "enabled";
const organizationId = typeof record.organizationId === "string" && record.organizationId.trim() ? record.organizationId.trim() : undefined;
if (!AI_KNOWLEDGE_SCOPES.includes(scope as AiKnowledgeScope)) {
return { success: false, reason: "知识库范围无效" };
}
if (!AI_KNOWLEDGE_STATUSES.includes(status as AiKnowledgeStatus)) {
return { success: false, reason: "知识库状态无效" };
}
if (!title) {
return { success: false, reason: "知识标题不能为空" };
}
if (!body) {
return { success: false, reason: "知识内容不能为空" };
}
return {
success: true,
input: {
scope: scope as AiKnowledgeScope,
organizationId,
title,
category,
tags,
body,
status: status as AiKnowledgeStatus,
},
};
}

View File

@@ -0,0 +1,832 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import {
Banknote,
CircleDollarSign,
Eye,
FilePlus2,
FileText,
HandCoins,
Plus,
ReceiptText,
Search,
TriangleAlert,
WalletCards,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import {
BILLING_STATUS_LABELS,
BILLING_STATUS_VALUES,
INVOICE_STATUS_LABELS,
PAYMENT_METHOD_LABELS,
PAYMENT_METHOD_VALUES,
addBillingCharge,
addBillingPayment,
calculateBillingSummary,
formatBillingPeriod,
formatCny,
toBillingStatementView,
} from "@/modules/billing/types";
import type {
BillingCharge,
BillingStatement,
BillingStatementView,
BillingStatus,
InvoiceStatus,
PaymentMethod,
} from "@/modules/billing/types";
type BillingWorkspaceClientProps = {
initialStatements: BillingStatement[];
};
type PaymentFormState = {
amountYuan: string;
method: PaymentMethod;
paidAt: string;
reference: string;
note: string;
};
type ChargeFormState = {
category: string;
description: string;
quantity: string;
serviceDate: string;
unitPriceYuan: string;
};
type DialogState =
| { kind: "detail"; statementId: string }
| { kind: "payment"; statementId: string; form: PaymentFormState }
| { kind: "charge"; statementId: string; form: ChargeFormState };
const STATUS_SORT_ORDER: Record<BillingStatus, number> = {
overdue: 0,
partial: 1,
pending: 2,
paid: 3,
};
const chargeCategoryOptions = [
{ value: "床位服务", label: "床位服务" },
{ value: "护理服务", label: "护理服务" },
{ value: "膳食服务", label: "膳食服务" },
{ value: "健康管理", label: "健康管理" },
{ value: "康复服务", label: "康复服务" },
{ value: "生活用品", label: "生活用品" },
{ value: "其他费用", label: "其他费用" },
];
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("zh-CN");
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
function toDateInputValue(date: Date): string {
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
return localDate.toISOString().slice(0, 10);
}
function toDateTimeInputValue(date: Date): string {
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
return localDate.toISOString().slice(0, 16);
}
function parseYuanToCents(value: string): number | undefined {
const amount = Number(value);
if (!Number.isFinite(amount) || amount <= 0) {
return undefined;
}
return Math.round(amount * 100);
}
function getStatusVariant(status: BillingStatus): "danger" | "secondary" | "success" | "warning" {
if (status === "paid") {
return "success";
}
if (status === "overdue") {
return "danger";
}
if (status === "partial") {
return "warning";
}
return "secondary";
}
function getInvoiceVariant(status: InvoiceStatus): "secondary" | "success" | "warning" {
if (status === "issued") {
return "success";
}
if (status === "pending") {
return "warning";
}
return "secondary";
}
export function BillingWorkspaceClient({ initialStatements }: BillingWorkspaceClientProps): React.ReactElement {
const [statements, setStatements] = useState(initialStatements);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | BillingStatus>("all");
const [dialog, setDialog] = useState<DialogState>();
const [message, setMessage] = useState("");
const [referenceDate] = useState(() => new Date());
const statementViews = useMemo(
() =>
statements
.map((statement) => toBillingStatementView(statement, referenceDate))
.sort((left, right) => STATUS_SORT_ORDER[left.status] - STATUS_SORT_ORDER[right.status]),
[referenceDate, statements],
);
const summary = useMemo(
() => calculateBillingSummary(statements, referenceDate),
[referenceDate, statements],
);
const normalizedQuery = query.trim().toLowerCase();
const filteredStatements = useMemo(
() =>
statementViews.filter((statement) => {
const matchesStatus = statusFilter === "all" || statement.status === statusFilter;
const matchesQuery =
!normalizedQuery ||
[
statement.id,
statement.elderName,
statement.roomLabel,
statement.contactName,
statement.contactPhone,
]
.join(" ")
.toLowerCase()
.includes(normalizedQuery);
return matchesStatus && matchesQuery;
}),
[normalizedQuery, statementViews, statusFilter],
);
const selectedStatement = dialog
? statementViews.find((statement) => statement.id === dialog.statementId)
: undefined;
function openPayment(statement: BillingStatementView): void {
setMessage("");
setDialog({
kind: "payment",
statementId: statement.id,
form: {
amountYuan: (statement.outstandingCents / 100).toFixed(2),
method: "wechat",
paidAt: toDateTimeInputValue(new Date()),
reference: "",
note: "",
},
});
}
function openCharge(statement: BillingStatementView): void {
setMessage("");
setDialog({
kind: "charge",
statementId: statement.id,
form: {
category: "护理服务",
description: "",
quantity: "1",
serviceDate: toDateInputValue(new Date()),
unitPriceYuan: "",
},
});
}
function updatePaymentForm(next: Partial<PaymentFormState>): void {
setDialog((current) =>
current?.kind === "payment" ? { ...current, form: { ...current.form, ...next } } : current,
);
}
function updateChargeForm(next: Partial<ChargeFormState>): void {
setDialog((current) =>
current?.kind === "charge" ? { ...current, form: { ...current.form, ...next } } : current,
);
}
function submitPayment(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
if (dialog?.kind !== "payment" || !selectedStatement) {
return;
}
const amountCents = parseYuanToCents(dialog.form.amountYuan);
if (!amountCents) {
setMessage("请输入有效的收款金额");
return;
}
if (amountCents > selectedStatement.outstandingCents) {
setMessage(`收款金额不能超过待收金额 ${formatCny(selectedStatement.outstandingCents)}`);
return;
}
const paidAt = new Date(dialog.form.paidAt);
if (Number.isNaN(paidAt.getTime())) {
setMessage("请选择有效的收款时间");
return;
}
const statementId = selectedStatement.id;
const payment = {
id: `${statementId}-payment-${Date.now()}`,
amountCents,
method: dialog.form.method,
paidAt: paidAt.toISOString(),
reference: dialog.form.reference.trim() || `PAY${Date.now()}`,
operator: "当前操作员",
note: dialog.form.note.trim(),
};
setStatements((current) =>
current.map((statement) =>
statement.id === statementId ? addBillingPayment(statement, payment) : statement,
),
);
setDialog({ kind: "detail", statementId });
setMessage(`${selectedStatement.elderName} 收款 ${formatCny(amountCents)} 已登记`);
}
function submitCharge(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
if (dialog?.kind !== "charge" || !selectedStatement) {
return;
}
const quantity = Number(dialog.form.quantity);
const unitPriceCents = parseYuanToCents(dialog.form.unitPriceYuan);
if (!dialog.form.description.trim()) {
setMessage("请输入费用项目说明");
return;
}
if (!Number.isFinite(quantity) || quantity <= 0) {
setMessage("请输入有效的数量");
return;
}
if (!unitPriceCents) {
setMessage("请输入有效的单价");
return;
}
const serviceDate = new Date(`${dialog.form.serviceDate}T12:00:00`);
if (Number.isNaN(serviceDate.getTime())) {
setMessage("请选择有效的服务日期");
return;
}
const statementId = selectedStatement.id;
const charge: BillingCharge = {
id: `${statementId}-charge-${Date.now()}`,
category: dialog.form.category,
description: dialog.form.description.trim(),
quantity,
serviceDate: serviceDate.toISOString(),
unitPriceCents,
};
setStatements((current) =>
current.map((statement) =>
statement.id === statementId ? addBillingCharge(statement, charge) : statement,
),
);
setDialog({ kind: "detail", statementId });
setMessage(`${selectedStatement.elderName} 新增费用 ${formatCny(Math.round(quantity * unitPriceCents))}`);
}
function generateReceipt(statement: BillingStatementView): void {
if (statement.payments.length === 0 || statement.receiptCount >= statement.payments.length) {
setMessage("当前收款记录均已生成收据");
return;
}
setStatements((current) =>
current.map((item) =>
item.id === statement.id ? { ...item, receiptCount: item.payments.length } : item,
),
);
setMessage(`${statement.elderName} 的最新收款收据已生成`);
}
function issueInvoice(statement: BillingStatementView): void {
if (statement.outstandingCents > 0) {
setMessage("账单结清后才能开具发票");
return;
}
if (statement.invoiceStatus === "issued") {
setMessage(`发票 ${statement.invoiceNumber ?? ""} 已开具`);
return;
}
const compactDate = new Date().toISOString().slice(0, 10).replaceAll("-", "");
const invoiceNumber = `FP${compactDate}${statement.id.slice(-3).toUpperCase()}`;
setStatements((current) =>
current.map((item) =>
item.id === statement.id ? { ...item, invoiceStatus: "issued", invoiceNumber } : item,
),
);
setMessage(`${statement.elderName} 的电子发票已开具:${invoiceNumber}`);
}
const dialogTitle =
dialog?.kind === "payment" ? "登记收款" : dialog?.kind === "charge" ? "登记费用" : "账单详情";
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={CircleDollarSign} label="本月应收" value={formatCny(summary.receivableCents)} />
<MetricCard icon={HandCoins} label="本月已收" value={formatCny(summary.collectedCents)} />
<MetricCard icon={WalletCards} label="待收金额" value={formatCny(summary.outstandingCents)} />
<MetricCard icon={TriangleAlert} label="逾期账户" value={`${summary.overdueCount}`} tone="danger" />
</section>
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="grid w-full gap-2 sm:grid-cols-2 lg:w-auto lg:min-w-[34rem]">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="搜索费用账单"
className="pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索老人、房间、联系人或账单号"
value={query}
/>
</label>
<Select
aria-label="账单状态"
onValueChange={(value) => setStatusFilter(value as "all" | BillingStatus)}
options={[
{ value: "all", label: "全部账单状态" },
...BILLING_STATUS_VALUES.map((status) => ({ value: status, label: BILLING_STATUS_LABELS[status] })),
]}
value={statusFilter}
/>
</div>
</section>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<Card>
<CardHeader className="gap-1">
<CardTitle></CardTitle>
<CardDescription> {filteredStatements.length} </CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[1040px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"> / </Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredStatements.map((statement) => (
<Table.Row key={statement.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{statement.elderName}</p>
<p className="text-xs text-muted-foreground">
{formatBillingPeriod(statement.period)} / {statement.id.toUpperCase()}
</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{statement.roomLabel}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium tabular-nums">{formatCny(statement.totalCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums text-muted-foreground">{formatCny(statement.paidCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium tabular-nums">{formatCny(statement.outstandingCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDate(statement.dueAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={getStatusVariant(statement.status)}>{BILLING_STATUS_LABELS[statement.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={getInvoiceVariant(statement.invoiceStatus)}>
{INVOICE_STATUS_LABELS[statement.invoiceStatus]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
aria-label={`查看 ${statement.elderName} 账单`}
onClick={() => setDialog({ kind: "detail", statementId: statement.id })}
size="icon"
title="查看账单"
type="button"
variant="outline"
>
<Eye className="size-4" aria-hidden="true" />
</Button>
<Button
aria-label={`登记 ${statement.elderName} 费用`}
onClick={() => openCharge(statement)}
size="icon"
title="登记费用"
type="button"
variant="outline"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
<Button
aria-label={`登记 ${statement.elderName} 收款`}
disabled={statement.outstandingCents === 0}
onClick={() => openPayment(statement)}
size="icon"
title="登记收款"
type="button"
>
<Banknote className="size-4" aria-hidden="true" />
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
{filteredStatements.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-10 text-center text-muted-foreground" colSpan={9}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog
description={selectedStatement ? `${selectedStatement.elderName} / ${formatBillingPeriod(selectedStatement.period)}` : undefined}
onClose={() => setDialog(undefined)}
open={dialog !== undefined && selectedStatement !== undefined}
title={dialogTitle}
width={dialog?.kind === "detail" ? "wide" : "lg"}
>
{dialog?.kind === "detail" && selectedStatement ? (
<StatementDetail
onCharge={() => openCharge(selectedStatement)}
onInvoice={() => issueInvoice(selectedStatement)}
onPayment={() => openPayment(selectedStatement)}
onReceipt={() => generateReceipt(selectedStatement)}
statement={selectedStatement}
/>
) : null}
{dialog?.kind === "payment" && selectedStatement ? (
<PaymentForm
form={dialog.form}
onCancel={() => setDialog({ kind: "detail", statementId: selectedStatement.id })}
onSubmit={submitPayment}
onUpdate={updatePaymentForm}
statement={selectedStatement}
/>
) : null}
{dialog?.kind === "charge" && selectedStatement ? (
<ChargeForm
form={dialog.form}
onCancel={() => setDialog({ kind: "detail", statementId: selectedStatement.id })}
onSubmit={submitCharge}
onUpdate={updateChargeForm}
/>
) : null}
</Dialog>
</div>
);
}
function StatementDetail({
onCharge,
onInvoice,
onPayment,
onReceipt,
statement,
}: {
onCharge: () => void;
onInvoice: () => void;
onPayment: () => void;
onReceipt: () => void;
statement: BillingStatementView;
}): React.ReactElement {
const canGenerateReceipt = statement.payments.length > statement.receiptCount;
const canIssueInvoice = statement.outstandingCents === 0 && statement.invoiceStatus !== "issued";
return (
<div className="grid gap-5">
<section className="grid gap-4 border-b pb-5 sm:grid-cols-2 lg:grid-cols-4">
<SummaryItem label="应收金额" value={formatCny(statement.totalCents)} />
<SummaryItem label="已收金额" value={formatCny(statement.paidCents)} />
<SummaryItem label="待收金额" value={formatCny(statement.outstandingCents)} />
<div>
<p className="text-xs text-muted-foreground"></p>
<Badge className="mt-2" variant={getStatusVariant(statement.status)}>
{BILLING_STATUS_LABELS[statement.status]}
</Badge>
</div>
</section>
<section className="grid gap-3 rounded-md border bg-secondary/25 p-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoItem label="房间床位" value={statement.roomLabel} />
<InfoItem label="缴费联系人" value={statement.contactName} />
<InfoItem label="联系电话" value={statement.contactPhone} />
<InfoItem label="到期日期" value={formatDate(statement.dueAt)} />
</section>
<section className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold"></h3>
<Button onClick={onCharge} size="sm" type="button" variant="outline">
<FilePlus2 className="size-4" aria-hidden="true" />
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[720px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{statement.charges.map((charge) => (
<Table.Row key={charge.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{charge.description}</p>
<p className="text-xs text-muted-foreground">{charge.category}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDate(charge.serviceDate)}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums">{charge.quantity}</Table.Cell>
<Table.Cell className="px-4 py-3 tabular-nums">{formatCny(charge.unitPriceCents)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-right font-medium tabular-nums">
{formatCny(Math.round(charge.quantity * charge.unitPriceCents))}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
</section>
<section className="grid gap-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold"></h3>
<p className="mt-1 text-xs text-muted-foreground">
{statement.receiptCount}/{statement.payments.length} · {INVOICE_STATUS_LABELS[statement.invoiceStatus]}
{statement.invoiceNumber ? ` / ${statement.invoiceNumber}` : ""}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={statement.outstandingCents === 0} onClick={onPayment} size="sm" type="button">
<Banknote className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canGenerateReceipt} onClick={onReceipt} size="sm" type="button" variant="outline">
<ReceiptText className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canIssueInvoice} onClick={onInvoice} size="sm" type="button" variant="outline">
<FileText className="size-4" aria-hidden="true" />
{statement.invoiceStatus === "issued" ? "发票已开具" : "开具发票"}
</Button>
</div>
</div>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[760px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{statement.payments.map((payment, index) => (
<Table.Row key={payment.id}>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(payment.paidAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">{PAYMENT_METHOD_LABELS[payment.method]}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{payment.reference}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{payment.operator}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={index < statement.receiptCount ? "success" : "secondary"}>
{index < statement.receiptCount ? "已生成" : "待生成"}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-right font-medium tabular-nums">{formatCny(payment.amountCents)}</Table.Cell>
</Table.Row>
))}
{statement.payments.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</section>
</div>
);
}
function PaymentForm({
form,
onCancel,
onSubmit,
onUpdate,
statement,
}: {
form: PaymentFormState;
onCancel: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onUpdate: (next: Partial<PaymentFormState>) => void;
statement: BillingStatementView;
}): React.ReactElement {
return (
<form className="grid gap-4" onSubmit={onSubmit}>
<div className="rounded-md border bg-secondary/25 p-4">
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatCny(statement.outstandingCents)}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Input
label="收款金额(元)"
max={(statement.outstandingCents / 100).toFixed(2)}
min="0.01"
onChange={(event) => onUpdate({ amountYuan: event.target.value })}
required
step="0.01"
type="number"
value={form.amountYuan}
/>
<Select
label="收款方式"
onValueChange={(value) => onUpdate({ method: value as PaymentMethod })}
options={PAYMENT_METHOD_VALUES.map((method) => ({ value: method, label: PAYMENT_METHOD_LABELS[method] }))}
value={form.method}
/>
<Input
label="收款时间"
onChange={(event) => onUpdate({ paidAt: event.target.value })}
required
type="datetime-local"
value={form.paidAt}
/>
<Input
label="支付流水号"
onChange={(event) => onUpdate({ reference: event.target.value })}
placeholder="留空后自动生成"
value={form.reference}
/>
</div>
<Textarea
label="备注"
onChange={(event) => onUpdate({ note: event.target.value })}
placeholder="填写付款人或对账说明"
value={form.note}
/>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button onClick={onCancel} type="button" variant="outline"></Button>
<Button type="submit">
<Banknote className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
);
}
function ChargeForm({
form,
onCancel,
onSubmit,
onUpdate,
}: {
form: ChargeFormState;
onCancel: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onUpdate: (next: Partial<ChargeFormState>) => void;
}): React.ReactElement {
return (
<form className="grid gap-4" onSubmit={onSubmit}>
<div className="grid gap-3 sm:grid-cols-2">
<Select
label="费用分类"
onValueChange={(value) => onUpdate({ category: value })}
options={chargeCategoryOptions}
value={form.category}
/>
<Input
label="服务日期"
onChange={(event) => onUpdate({ serviceDate: event.target.value })}
required
type="date"
value={form.serviceDate}
/>
</div>
<Input
label="费用项目说明"
onChange={(event) => onUpdate({ description: event.target.value })}
placeholder="例如:临时陪诊服务"
required
value={form.description}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Input
label="数量"
min="0.01"
onChange={(event) => onUpdate({ quantity: event.target.value })}
required
step="0.01"
type="number"
value={form.quantity}
/>
<Input
label="单价(元)"
min="0.01"
onChange={(event) => onUpdate({ unitPriceYuan: event.target.value })}
required
step="0.01"
type="number"
value={form.unitPriceYuan}
/>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button onClick={onCancel} type="button" variant="outline"></Button>
<Button type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
);
}
function MetricCard({
icon: Icon,
label,
tone = "default",
value,
}: {
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
label: string;
tone?: "danger" | "default";
value: string;
}): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3 p-4">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{label}</p>
<CardTitle className="mt-2 truncate text-2xl tabular-nums">{value}</CardTitle>
</div>
<Icon className={tone === "danger" ? "size-5 shrink-0 text-destructive" : "size-5 shrink-0 text-primary"} aria-hidden={true} />
</CardHeader>
</Card>
);
}
function SummaryItem({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-sm font-medium">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,212 @@
import type { BillingCharge, BillingPayment, BillingStatement, PaymentMethod } from "@/modules/billing/types";
type StatementSeed = {
id: string;
elderId: string;
elderName: string;
roomLabel: string;
contactName: string;
contactPhone: string;
dueOffsetDays: number;
charges: Array<Omit<BillingCharge, "id" | "serviceDate">>;
payments: Array<{
amountCents: number;
method: PaymentMethod;
paidOffsetDays: number;
reference: string;
operator: string;
note: string;
}>;
invoiceStatus?: BillingStatement["invoiceStatus"];
invoiceNumber?: string;
receiptCount?: number;
};
const STATEMENT_SEEDS: StatementSeed[] = [
{
id: "bill-wgl",
elderId: "elder-wgl",
elderName: "王桂兰",
roomLabel: "A101-1",
contactName: "张敏",
contactPhone: "13800000001",
dueOffsetDays: 6,
charges: [
{ category: "床位服务", description: "标准护理房床位费", quantity: 1, unitPriceCents: 360000 },
{ category: "护理服务", description: "重点照护服务包", quantity: 1, unitPriceCents: 390000 },
{ category: "膳食服务", description: "低盐营养膳食", quantity: 1, unitPriceCents: 180000 },
{ category: "健康管理", description: "血压跟踪与健康复核", quantity: 1, unitPriceCents: 30000 },
],
payments: [
{
amountCents: 960000,
method: "wechat",
paidOffsetDays: -2,
reference: "WX202607090018",
operator: "前台收费组",
note: "家属线上支付",
},
],
invoiceStatus: "issued",
invoiceNumber: "FP20260709001",
receiptCount: 1,
},
{
id: "bill-ljg",
elderId: "elder-ljg",
elderName: "李建国",
roomLabel: "A102-1",
contactName: "李明",
contactPhone: "13800000002",
dueOffsetDays: 8,
charges: [
{ category: "床位服务", description: "半护理房床位费", quantity: 1, unitPriceCents: 320000 },
{ category: "护理服务", description: "半护理服务包", quantity: 1, unitPriceCents: 250000 },
{ category: "膳食服务", description: "日常营养膳食", quantity: 1, unitPriceCents: 170000 },
{ category: "活动服务", description: "康体活动与陪同", quantity: 1, unitPriceCents: 40000 },
],
payments: [
{
amountCents: 400000,
method: "bank_transfer",
paidOffsetDays: -1,
reference: "BANK20260708032",
operator: "财务值班",
note: "首笔转账已确认",
},
],
invoiceStatus: "pending",
},
{
id: "bill-zyz",
elderId: "elder-zyz",
elderName: "周玉珍",
roomLabel: "A201-1",
contactName: "周强",
contactPhone: "13800000003",
dueOffsetDays: 10,
charges: [
{ category: "床位服务", description: "记忆照护专区床位费", quantity: 1, unitPriceCents: 370000 },
{ category: "护理服务", description: "协助护理服务包", quantity: 1, unitPriceCents: 285000 },
{ category: "膳食服务", description: "糖尿病营养膳食", quantity: 1, unitPriceCents: 185000 },
],
payments: [],
},
{
id: "bill-zgh",
elderId: "elder-zgh",
elderName: "赵国华",
roomLabel: "A202-1",
contactName: "赵琳",
contactPhone: "13800000004",
dueOffsetDays: -3,
charges: [
{ category: "床位服务", description: "重点护理房床位费", quantity: 1, unitPriceCents: 380000 },
{ category: "护理服务", description: "失能照护服务包", quantity: 1, unitPriceCents: 420000 },
{ category: "膳食服务", description: "软食营养膳食", quantity: 1, unitPriceCents: 185000 },
{ category: "生活用品", description: "护理耗材包", quantity: 1, unitPriceCents: 60000 },
],
payments: [
{
amountCents: 200000,
method: "cash",
paidOffsetDays: -6,
reference: "CASH20260703009",
operator: "前台收费组",
note: "现金预缴",
},
],
},
{
id: "bill-msf",
elderId: "elder-msf",
elderName: "马淑芬",
roomLabel: "A301-1",
contactName: "马晨",
contactPhone: "13800000011",
dueOffsetDays: -5,
charges: [
{ category: "床位服务", description: "失能照护区床位费", quantity: 1, unitPriceCents: 390000 },
{ category: "护理服务", description: "全护理服务包", quantity: 1, unitPriceCents: 460000 },
{ category: "膳食服务", description: "吞咽困难软食套餐", quantity: 1, unitPriceCents: 205000 },
{ category: "营养服务", description: "营养师评估与加餐", quantity: 1, unitPriceCents: 65000 },
],
payments: [],
},
{
id: "bill-qsb",
elderId: "elder-qsb",
elderName: "钱松柏",
roomLabel: "S101-1",
contactName: "钱宁",
contactPhone: "13800000014",
dueOffsetDays: 4,
charges: [
{ category: "床位服务", description: "医养观察房床位费", quantity: 1, unitPriceCents: 430000 },
{ category: "护理服务", description: "医养重点照护服务包", quantity: 1, unitPriceCents: 480000 },
{ category: "膳食服务", description: "呼吸慢病营养膳食", quantity: 1, unitPriceCents: 190000 },
{ category: "设备服务", description: "制氧与血氧监测服务", quantity: 1, unitPriceCents: 150000 },
],
payments: [
{
amountCents: 1250000,
method: "card",
paidOffsetDays: -1,
reference: "POS20260708017",
operator: "医养前台",
note: "银行卡支付",
},
],
receiptCount: 1,
},
];
function addDays(reference: Date, days: number): string {
const result = new Date(reference);
result.setDate(result.getDate() + days);
return result.toISOString();
}
function getPeriod(reference: Date): string {
const year = reference.getFullYear();
const month = String(reference.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function createCharges(seed: StatementSeed, reference: Date): BillingCharge[] {
return seed.charges.map((charge, index) => ({
...charge,
id: `${seed.id}-charge-${index + 1}`,
serviceDate: addDays(reference, -Math.max(1, 12 - index * 2)),
}));
}
function createPayments(seed: StatementSeed, reference: Date): BillingPayment[] {
return seed.payments.map((payment, index) => ({
id: `${seed.id}-payment-${index + 1}`,
amountCents: payment.amountCents,
method: payment.method,
paidAt: addDays(reference, payment.paidOffsetDays),
reference: payment.reference,
operator: payment.operator,
note: payment.note,
}));
}
export function createInitialBillingStatements(referenceDate = new Date()): BillingStatement[] {
return STATEMENT_SEEDS.map((seed) => ({
id: seed.id,
elderId: seed.elderId,
elderName: seed.elderName,
roomLabel: seed.roomLabel,
contactName: seed.contactName,
contactPhone: seed.contactPhone,
period: getPeriod(referenceDate),
dueAt: addDays(referenceDate, seed.dueOffsetDays),
charges: createCharges(seed, referenceDate),
payments: createPayments(seed, referenceDate),
invoiceStatus: seed.invoiceStatus ?? "not_requested",
invoiceNumber: seed.invoiceNumber,
receiptCount: seed.receiptCount ?? 0,
}));
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { createInitialBillingStatements } from "@/modules/billing/lib/presentation-data";
import {
addBillingCharge,
addBillingPayment,
calculateBillingSummary,
getBillingStatus,
getStatementOutstandingCents,
getStatementTotalCents,
} from "@/modules/billing/types";
const referenceDate = new Date("2026-07-09T12:00:00.000Z");
describe("billing calculations", () => {
it("derives paid, partial, pending, and overdue states from ledger activity", () => {
const statements = createInitialBillingStatements(referenceDate);
expect(statements.map((statement) => getBillingStatus(statement, referenceDate))).toEqual([
"paid",
"partial",
"pending",
"overdue",
"overdue",
"paid",
]);
});
it("calculates statement and workspace totals from charges and payments", () => {
const statements = createInitialBillingStatements(referenceDate);
const summary = calculateBillingSummary(statements, referenceDate);
const firstStatement = statements[0];
expect(firstStatement).toBeDefined();
if (!firstStatement) {
return;
}
expect(getStatementTotalCents(firstStatement)).toBe(960000);
expect(summary.receivableCents).toBe(5995000);
expect(summary.collectedCents).toBe(2810000);
expect(summary.outstandingCents).toBe(3185000);
expect(summary.overdueCount).toBe(2);
});
it("updates outstanding amounts when payments and charges are appended", () => {
const statement = createInitialBillingStatements(referenceDate)[2];
expect(statement).toBeDefined();
if (!statement) {
return;
}
const paidStatement = addBillingPayment(statement, {
id: "payment-new",
amountCents: 200000,
method: "wechat",
paidAt: referenceDate.toISOString(),
reference: "WX-NEW",
operator: "收费员",
note: "",
});
expect(getStatementOutstandingCents(paidStatement)).toBe(640000);
expect(getBillingStatus(paidStatement, referenceDate)).toBe("partial");
const chargedStatement = addBillingCharge(paidStatement, {
id: "charge-new",
category: "生活用品",
description: "护理耗材",
quantity: 2,
serviceDate: referenceDate.toISOString(),
unitPriceCents: 5000,
});
expect(getStatementOutstandingCents(chargedStatement)).toBe(650000);
expect(chargedStatement.invoiceStatus).toBe("not_requested");
});
});

169
modules/billing/types.ts Normal file
View File

@@ -0,0 +1,169 @@
export const BILLING_STATUS_VALUES = ["paid", "partial", "pending", "overdue"] as const;
export type BillingStatus = (typeof BILLING_STATUS_VALUES)[number];
export const BILLING_STATUS_LABELS: Record<BillingStatus, string> = {
paid: "已结清",
partial: "部分收款",
pending: "待收款",
overdue: "已逾期",
};
export const PAYMENT_METHOD_VALUES = ["wechat", "alipay", "bank_transfer", "card", "cash"] as const;
export type PaymentMethod = (typeof PAYMENT_METHOD_VALUES)[number];
export const PAYMENT_METHOD_LABELS: Record<PaymentMethod, string> = {
wechat: "微信支付",
alipay: "支付宝",
bank_transfer: "银行转账",
card: "银行卡",
cash: "现金",
};
export const INVOICE_STATUS_VALUES = ["not_requested", "pending", "issued"] as const;
export type InvoiceStatus = (typeof INVOICE_STATUS_VALUES)[number];
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
not_requested: "未开票",
pending: "待开票",
issued: "已开票",
};
export type BillingCharge = {
id: string;
category: string;
description: string;
quantity: number;
serviceDate: string;
unitPriceCents: number;
};
export type BillingPayment = {
id: string;
amountCents: number;
method: PaymentMethod;
paidAt: string;
reference: string;
operator: string;
note: string;
};
export type BillingStatement = {
id: string;
elderId: string;
elderName: string;
roomLabel: string;
contactName: string;
contactPhone: string;
period: string;
dueAt: string;
charges: BillingCharge[];
payments: BillingPayment[];
invoiceStatus: InvoiceStatus;
invoiceNumber?: string;
receiptCount: number;
};
export type BillingStatementView = BillingStatement & {
totalCents: number;
paidCents: number;
outstandingCents: number;
status: BillingStatus;
};
export type BillingSummary = {
receivableCents: number;
collectedCents: number;
outstandingCents: number;
overdueCount: number;
};
export function getChargeAmountCents(charge: BillingCharge): number {
return Math.round(charge.quantity * charge.unitPriceCents);
}
export function getStatementTotalCents(statement: BillingStatement): number {
return statement.charges.reduce((total, charge) => total + getChargeAmountCents(charge), 0);
}
export function getStatementPaidCents(statement: BillingStatement): number {
return statement.payments.reduce((total, payment) => total + payment.amountCents, 0);
}
export function getStatementOutstandingCents(statement: BillingStatement): number {
return Math.max(0, getStatementTotalCents(statement) - getStatementPaidCents(statement));
}
export function getBillingStatus(statement: BillingStatement, referenceDate = new Date()): BillingStatus {
const outstandingCents = getStatementOutstandingCents(statement);
if (outstandingCents === 0) {
return "paid";
}
if (new Date(statement.dueAt).getTime() < referenceDate.getTime()) {
return "overdue";
}
return statement.payments.length > 0 ? "partial" : "pending";
}
export function toBillingStatementView(
statement: BillingStatement,
referenceDate = new Date(),
): BillingStatementView {
const totalCents = getStatementTotalCents(statement);
const paidCents = getStatementPaidCents(statement);
return {
...statement,
totalCents,
paidCents,
outstandingCents: Math.max(0, totalCents - paidCents),
status: getBillingStatus(statement, referenceDate),
};
}
export function calculateBillingSummary(
statements: readonly BillingStatement[],
referenceDate = new Date(),
): BillingSummary {
return statements.reduce<BillingSummary>(
(summary, statement) => {
const view = toBillingStatementView(statement, referenceDate);
return {
receivableCents: summary.receivableCents + view.totalCents,
collectedCents: summary.collectedCents + view.paidCents,
outstandingCents: summary.outstandingCents + view.outstandingCents,
overdueCount: summary.overdueCount + (view.status === "overdue" ? 1 : 0),
};
},
{ receivableCents: 0, collectedCents: 0, outstandingCents: 0, overdueCount: 0 },
);
}
export function addBillingPayment(statement: BillingStatement, payment: BillingPayment): BillingStatement {
return {
...statement,
payments: [payment, ...statement.payments],
};
}
export function addBillingCharge(statement: BillingStatement, charge: BillingCharge): BillingStatement {
return {
...statement,
charges: [...statement.charges, charge],
invoiceStatus: "not_requested",
invoiceNumber: undefined,
};
}
export function formatCny(cents: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
minimumFractionDigits: 2,
}).format(cents / 100);
}
export function formatBillingPeriod(period: string): string {
const [year, month] = period.split("-");
return year && month ? `${year}${month}` : period;
}

View File

@@ -0,0 +1,7 @@
export const DEFAULT_INVITATION_VALIDITY_DAYS = 7;
export const MIN_INVITATION_VALIDITY_DAYS = 1;
export const MAX_INVITATION_VALIDITY_DAYS = 365;
export const DEFAULT_INVITATION_MAX_USES = 1;
export const MIN_INVITATION_MAX_USES = 1;
export const MAX_INVITATION_MAX_USES = 100;

View File

@@ -1,6 +1,6 @@
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { and, eq, gt, isNull } from "drizzle-orm";
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
import { cookies } from "next/headers";
import { jsonFailure } from "@/modules/core/server/api";
@@ -324,12 +324,21 @@ export async function createRegistration(input: {
.limit(1)
: [];
const invitation = invitationRows[0];
const now = new Date();
if (input.invitationToken && !invitation) {
throw new Error("邀请链接无效");
}
if (invitation && (invitation.invitation.status !== "active" || invitation.invitation.expiresAt < new Date())) {
if (
invitation &&
(invitation.invitation.status !== "active" ||
invitation.invitation.expiresAt < now ||
invitation.invitation.usedCount >= invitation.invitation.maxUses)
) {
throw new Error("邀请链接已失效");
}
if (invitation?.invitation.email && normalizeEmail(invitation.invitation.email) !== normalizedEmail) {
throw new Error("邀请邮箱与注册邮箱不一致");
}
if (!invitation && !settings.registrationEnabled) {
throw new Error("系统暂未开放注册");
}
@@ -372,6 +381,30 @@ export async function createRegistration(input: {
roleId: invitation.invitation.roleId,
status: "active",
});
const consumedRows = await transaction
.update(organizationInvitations)
.set({
usedCount: sql`${organizationInvitations.usedCount} + 1`,
updatedAt: new Date(),
})
.where(
and(
eq(organizationInvitations.id, invitation.invitation.id),
eq(organizationInvitations.status, "active"),
gte(organizationInvitations.expiresAt, now),
lt(organizationInvitations.usedCount, organizationInvitations.maxUses),
),
)
.returning({
id: organizationInvitations.id,
maxUses: organizationInvitations.maxUses,
usedCount: organizationInvitations.usedCount,
});
const consumed = consumedRows[0];
if (!consumed) {
throw new Error("邀请链接已失效");
}
if (consumed.usedCount >= consumed.maxUses) {
await transaction
.update(organizationInvitations)
.set({
@@ -380,7 +413,8 @@ export async function createRegistration(input: {
acceptedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(organizationInvitations.id, invitation.invitation.id));
.where(eq(organizationInvitations.id, consumed.id));
}
}
const sessionRows = await transaction

View File

@@ -0,0 +1,326 @@
import { describe, expect, it, vi } from "vitest";
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
import { getDatabase, type AppDatabase } from "@/modules/core/server/db";
import {
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,
seedDefaultCollaborationWorkspaceData,
} from "@/modules/core/server/default-workspace-data";
const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i;
vi.mock("server-only", () => ({}));
vi.mock("@/modules/core/server/db", () => ({
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", () => {
it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3);
const scopes = new Set(DEFAULT_AI_KNOWLEDGE_ENTRIES.map((entry) => entry.scope));
expect(scopes).toEqual(new Set(["platform", "organization"]));
for (const entry of DEFAULT_AI_KNOWLEDGE_ENTRIES) {
expect(validateKnowledgeEntryInput(entry)).toEqual({ success: true, input: entry });
expect(entry.status).toBe("enabled");
expect(entry.body.length).toBeGreaterThan(80);
expect([entry.title, entry.category, entry.tags, entry.body].join("\n")).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
}
});
it("provides completed elder AI analyses that current validators can display", () => {
expect(DEFAULT_PREPARED_AI_ANALYSES.length).toBeGreaterThanOrEqual(1);
for (const analysis of DEFAULT_PREPARED_AI_ANALYSES) {
const validated = validateElderAiAnalysisOutput(analysis.result);
expect(validated).toEqual(analysis.result);
expect(analysis.elderName).toBeTruthy();
expect(analysis.dataScopes).toContain("elder");
expect(analysis.dataScopes).toContain("knowledge");
expect(analysis.createdHoursAgo).toBeGreaterThan(0);
expect(analysis.result.citations.length).toBeGreaterThan(0);
expect(analysis.result.keyFindings.length).toBeGreaterThan(0);
expect(analysis.result.recommendations.length).toBeGreaterThan(0);
expect(JSON.stringify(analysis)).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
}
});
it("keeps every prepared analysis citation reference displayable", () => {
for (const analysis of DEFAULT_PREPARED_AI_ANALYSES) {
const citationIds = new Set(analysis.result.citations.map((citation) => citation.id));
const referencedIds = [
...analysis.result.keyFindings.flatMap((finding) => finding.citationIds),
...analysis.result.recommendations.flatMap((recommendation) => recommendation.citationIds),
];
expect(referencedIds.length).toBeGreaterThan(0);
expect(new Set(referencedIds)).toEqual(citationIds);
for (const citationId of referencedIds) {
expect(citationIds.has(citationId)).toBe(true);
}
}
});
});

View File

@@ -1,8 +1,11 @@
import { eq } from "drizzle-orm";
import { and, eq, isNull, or } from "drizzle-orm";
import type { ElderAiAnalysisOutput } from "@/modules/ai/types";
import { getDatabase } from "@/modules/core/server/db";
import {
admissions,
aiKnowledgeChunks,
aiKnowledgeEntries,
alertRules,
alertTriggers,
beds,
@@ -11,6 +14,7 @@ import {
careTasks,
chronicConditions,
deviceAssets,
elderAiAnalyses,
elders,
familyContacts,
familyFeedback,
@@ -198,6 +202,28 @@ type SeedFamilyFeedback = {
status: "open" | "in_progress" | "resolved" | "closed";
};
type SeedAiKnowledgeEntry = {
scope: "platform" | "organization";
title: string;
category: string;
tags: string;
body: string;
status: "enabled" | "disabled";
};
type SeedPreparedAiAnalysis = {
elderName: string;
createdHoursAgo: number;
dataScopes: string[];
result: ElderAiAnalysisOutput;
};
const DEFAULT_AI_MODEL_SUMMARY = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
@@ -433,8 +459,8 @@ const DEFAULT_ELDERS: SeedElder[] = [
];
const DEFAULT_ADMISSIONS: SeedAdmission[] = [
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "首次入住评估已归档" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "照护计划已同步至护理组" },
{ elderName: "周玉珍", bedCode: "A201-1", status: "active", notes: "记忆照护专区入住" },
{ elderName: "赵国华", bedCode: "A202-1", status: "active", notes: "重点照护入住" },
{ elderName: "孙秀梅", bedCode: "R101-1", status: "active", notes: "康复观察入住" },
@@ -902,7 +928,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
status: "open",
title: "床头呼叫器待检修",
description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。",
source: "默认工作区初始化",
source: "设施巡检",
createdHoursAgo: 2,
},
{
@@ -910,7 +936,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
status: "acknowledged",
title: "本周护理评估待完成",
description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。",
source: "默认工作区初始化",
source: "护理排班",
createdHoursAgo: 6,
acknowledgedHoursAgo: 5,
},
@@ -918,8 +944,8 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
severity: "critical",
status: "resolved",
title: "夜间巡房异常已处理",
description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。",
source: "默认工作区初始化",
description: "重点照护房夜间巡房提醒已恢复,事件记录已归档便于后续追踪。",
source: "夜间巡房系统",
createdHoursAgo: 14,
acknowledgedHoursAgo: 13,
resolvedHoursAgo: 11,
@@ -953,7 +979,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
},
];
const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
export const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
{
name: "A102 床头呼叫器",
code: "DEV-CALL-A102-2",
@@ -992,7 +1018,7 @@ const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
},
];
const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
export const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
{
deviceCode: "DEV-CALL-A102-2",
title: "床头呼叫器按键失灵",
@@ -1022,7 +1048,7 @@ const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
},
];
const DEFAULT_NOTICES: SeedNotice[] = [
export const DEFAULT_NOTICES: SeedNotice[] = [
{
title: "本周家属探访安排",
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
@@ -1045,7 +1071,7 @@ const DEFAULT_NOTICES: SeedNotice[] = [
},
];
const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
export const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
{
name: "血氧低值连续告警",
ruleType: "health",
@@ -1072,7 +1098,7 @@ const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
},
];
const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
export const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
{
ruleName: "血氧低值连续告警",
elderName: "钱松柏",
@@ -1104,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: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", 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: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
];
const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
export const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
{
elderName: "赵国华",
contactName: "赵蕾",
@@ -1136,6 +1162,619 @@ const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
},
];
export const DEFAULT_AI_KNOWLEDGE_ENTRIES: SeedAiKnowledgeEntry[] = [
{
scope: "platform",
title: "夜间低血氧处理流程",
category: "医疗护理SOP",
tags: "血氧,吸氧,夜间,慢阻肺,应急复核",
body: "夜间连续血氧低于 92% 时,护理组需先确认探头位置、手部温度和体位,再记录复测值。血氧低于 90% 或伴随呼吸困难、口唇紫绀、意识改变时,立即通知值班医生并启动应急处置。慢阻肺老人应同步检查吸氧设备、管路连接和医嘱流量,复核后在生命体征、护理任务和事件记录中闭环。",
status: "enabled",
},
{
scope: "platform",
title: "跌倒高风险住民照护要点",
category: "安全照护SOP",
tags: "防跌倒,夜间巡房,转移位,环境安全",
body: "跌倒高风险老人应保持床旁通道清晰,夜间保留低位照明,呼叫器放在伸手可及位置。转移位、如厕和康复训练需按照照护等级安排单人或双人协助;近期血压波动、认知障碍、术后康复、骨质疏松老人应提高巡视频次。发生跌倒或险情后,先评估意识、疼痛和活动能力,再通知家属并记录完整经过。",
status: "enabled",
},
{
scope: "organization",
title: "重点照护房夜间巡房规范",
category: "机构制度",
tags: "夜班,巡房,重点照护,交接班",
body: "重点照护房夜班至少每两小时巡房一次;存在血氧异常、吞咽风险、卧床压疮风险或夜间迷路风险的老人需补充专项观察。巡房记录应包含老人状态、体位、设备连接、呼叫器可用性和异常处理结果。未按计划完成的巡房任务需要夜班主管在交接班前补录原因和后续安排。",
status: "enabled",
},
{
scope: "organization",
title: "吞咽困难软食与呛咳观察",
category: "营养照护",
tags: "吞咽,软食,呛咳,营养,体重",
body: "吞咽困难老人进食时保持坐位,餐中小口慢食,避免催促。软食、增稠液和营养补充剂按医嘱执行,餐后半小时内不平卧。出现连续呛咳、声音湿哑、口唇发紫、进食量下降或体重持续下降时,应通知医生、营养师和家属,并将观察结果同步到护理任务。",
status: "enabled",
},
{
scope: "organization",
title: "异常情况家属同步规则",
category: "服务沟通",
tags: "家属沟通,异常同步,护理反馈,探访",
body: "涉及跌倒、血氧异常、发热、连续拒食、护理任务明显延误或设备故障影响照护时,责任护理组应在处置完成后同步家属。沟通内容包含已发现的问题、已采取的措施、下一步观察计划和预计反馈时间;对仍在处理中事项,应在交接班中标注负责人,避免多次解释不一致。",
status: "enabled",
},
];
export const DEFAULT_PREPARED_AI_ANALYSES: SeedPreparedAiAnalysis[] = [
{
elderName: "钱松柏",
createdHoursAgo: 1,
dataScopes: ["elder", "health", "care", "incident", "facility", "knowledge"],
result: {
overallRiskLevel: "critical",
summary: "钱松柏夜间血氧低于 90%,且既往有慢阻肺和夜间吸氧记录,应优先完成呼吸状态复核、吸氧设备检查和医生评估。",
keyFindings: [
{
category: "呼吸风险",
severity: "critical",
evidence: "最新设备记录显示夜间血氧 89%,慢阻肺病史和长期吸入治疗增加夜间低氧风险。",
citationIds: ["resident-qsb-vitals", "kb-night-spo2"],
},
{
category: "设备闭环",
severity: "warning",
evidence: "系统事件已记录 S101 医养观察房血氧告警,需要确认吸氧设备、管路和医嘱流量。",
citationIds: ["resident-qsb-incident", "kb-night-spo2"],
},
],
recommendations: [
{
title: "立即复测血氧并通知值班医生",
priority: "urgent",
rationale: "血氧低于 90% 属于需要即时复核的高风险信号,且老人存在慢阻肺基础病。",
suggestedNextStep: "夜班护理组先复测血氧、检查吸氧设备和体位,再将复核结果同步给值班医生。",
citationIds: ["resident-qsb-vitals", "kb-night-spo2"],
},
{
title: "把低氧事件纳入交接班追踪",
priority: "high",
rationale: "告警、设备检查和医疗复核需要跨班次闭环,避免只完成单点处置。",
suggestedNextStep: "在护理任务中补充复测时间、吸氧流量确认结果和下一次观察时间。",
citationIds: ["resident-qsb-incident"],
},
],
dataGaps: ["缺少最近一次医生复核结论和吸氧流量确认记录。"],
citations: [
{
id: "resident-qsb-vitals",
sourceType: "resident_context",
sourceId: "elder:钱松柏:vitals",
title: "钱松柏生命体征与慢病记录",
excerpt: "慢阻肺,夜间吸氧;最新血氧记录 89%,心率 104需关注呼吸状态。",
},
{
id: "resident-qsb-incident",
sourceType: "resident_context",
sourceId: "elder:钱松柏:incident",
title: "S101 医养观察房血氧告警",
excerpt: "钱松柏夜间血氧持续低于阈值,需要护理组和医生复核。",
},
{
id: "kb-night-spo2",
sourceType: "knowledge",
sourceId: "夜间低血氧处理流程",
title: "夜间低血氧处理流程",
excerpt: "血氧低于 90% 或伴随呼吸困难、口唇紫绀、意识改变时,立即通知值班医生并启动应急处置。",
},
],
confidence: 0.86,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
{
elderName: "马淑芬",
createdHoursAgo: 3,
dataScopes: ["elder", "health", "care", "knowledge"],
result: {
overallRiskLevel: "high",
summary: "马淑芬存在吞咽困难、近期体重下降和晚餐软食观察任务,当前重点是防呛咳、保证摄入量并完成营养师复评。",
keyFindings: [
{
category: "吞咽与营养",
severity: "warning",
evidence: "健康档案记录脑梗后吞咽功能下降、近期体重下降;护理计划要求晚餐软食并观察呛咳。",
citationIds: ["resident-msf-health", "kb-swallowing"],
},
{
category: "护理优先级",
severity: "warning",
evidence: "晚餐软食进食观察任务为紧急优先级,需在进食时段完成并记录摄入情况。",
citationIds: ["resident-msf-care"],
},
],
recommendations: [
{
title: "进食时段安排专人观察",
priority: "high",
rationale: "吞咽风险与体重下降同时出现,餐中观察比事后补记更能降低呛咳风险。",
suggestedNextStep: "重点护理组在晚餐时记录坐位、进食量、呛咳情况和餐后半小时体位。",
citationIds: ["resident-msf-care", "kb-swallowing"],
},
{
title: "同步营养师复评体重趋势",
priority: "normal",
rationale: "近期体重偏低已进入复核,需结合摄入量决定是否调整加餐。",
suggestedNextStep: "汇总三天进食量、体重和呛咳观察后交由营养师复评。",
citationIds: ["resident-msf-health"],
},
],
dataGaps: ["缺少最近三天完整进食量记录。"],
citations: [
{
id: "resident-msf-health",
sourceType: "resident_context",
sourceId: "elder:马淑芬:health",
title: "马淑芬健康档案",
excerpt: "脑梗后吞咽功能下降,近期体重下降;软食,进食时保持坐位,餐后半小时内不平卧。",
},
{
id: "resident-msf-care",
sourceType: "resident_context",
sourceId: "elder:马淑芬:care",
title: "晚餐软食进食观察任务",
excerpt: "晚餐软食进食观察为紧急护理任务,需记录进食和呛咳情况。",
},
{
id: "kb-swallowing",
sourceType: "knowledge",
sourceId: "吞咽困难软食与呛咳观察",
title: "吞咽困难软食与呛咳观察",
excerpt: "吞咽困难老人进食时保持坐位,餐中小口慢食;出现连续呛咳、声音湿哑或进食量下降时应同步医生、营养师和家属。",
},
],
confidence: 0.8,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
{
elderName: "王桂兰",
createdHoursAgo: 5,
dataScopes: ["elder", "health", "care", "family", "knowledge"],
result: {
overallRiskLevel: "medium",
summary: "王桂兰近期血压偏高但记录链路完整,建议继续晨晚复测、观察胸闷症状,并在家属探访前同步照护重点。",
keyFindings: [
{
category: "血压趋势",
severity: "warning",
evidence: "近期生命体征显示血压偏高,健康档案要求晨晚血压记录并在胸闷时立即联系医生。",
citationIds: ["resident-wgl-vitals"],
},
{
category: "家属沟通",
severity: "info",
evidence: "主要联系人每周六探访,可在探访前同步血压复测和低盐饮食执行情况。",
citationIds: ["resident-wgl-family", "kb-family-sync"],
},
],
recommendations: [
{
title: "保持晨晚血压复测",
priority: "normal",
rationale: "当前为持续偏高而非单次异常,连续趋势比单点数值更适合指导照护安排。",
suggestedNextStep: "护理组在晨晚复测后记录数值、用药时间和是否胸闷。",
citationIds: ["resident-wgl-vitals"],
},
{
title: "探访前同步照护重点",
priority: "low",
rationale: "家属是主要联系人,提前同步可减少探访时的信息差。",
suggestedNextStep: "前台或责任护理员在探访前准备血压趋势、饮食执行和护理安排摘要。",
citationIds: ["resident-wgl-family", "kb-family-sync"],
},
],
dataGaps: [],
citations: [
{
id: "resident-wgl-vitals",
sourceType: "resident_context",
sourceId: "elder:王桂兰:vitals",
title: "王桂兰生命体征与健康档案",
excerpt: "高血压十余年;最新血压偏高,需晨晚血压记录,胸闷或血压持续升高时立即联系医生。",
},
{
id: "resident-wgl-family",
sourceType: "resident_context",
sourceId: "elder:王桂兰:family",
title: "王桂兰家属联系人",
excerpt: "主要联系人张敏,每周六探访。",
},
{
id: "kb-family-sync",
sourceType: "knowledge",
sourceId: "异常情况家属同步规则",
title: "异常情况家属同步规则",
excerpt: "沟通内容包含已发现的问题、已采取的措施、下一步观察计划和预计反馈时间。",
},
],
confidence: 0.74,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
];
function buildSeedKnowledgeContent(entry: SeedAiKnowledgeEntry): string {
return [entry.title, entry.category, entry.tags, entry.body].filter(Boolean).join("\n");
}
function getSeedKnowledgeKey(entry: Pick<SeedAiKnowledgeEntry, "scope" | "title">): string {
return `${entry.scope}:${entry.title}`;
}
async function seedDefaultAiWorkspaceData(organizationId: string): Promise<void> {
const database = getDatabase();
const [existingKnowledgeRows, elderRows, existingAnalysisRows] = await Promise.all([
database
.select({ scope: aiKnowledgeEntries.scope, title: aiKnowledgeEntries.title })
.from(aiKnowledgeEntries)
.where(
or(
and(eq(aiKnowledgeEntries.scope, "platform"), isNull(aiKnowledgeEntries.organizationId)),
and(eq(aiKnowledgeEntries.scope, "organization"), eq(aiKnowledgeEntries.organizationId, organizationId)),
),
),
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
database.select({ elderId: elderAiAnalyses.elderId }).from(elderAiAnalyses).where(eq(elderAiAnalyses.organizationId, organizationId)),
]);
const existingKnowledgeKeys = new Set(existingKnowledgeRows.map(getSeedKnowledgeKey));
const missingKnowledgeEntries = DEFAULT_AI_KNOWLEDGE_ENTRIES.filter((entry) => !existingKnowledgeKeys.has(getSeedKnowledgeKey(entry)));
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
const analyzedElderIds = new Set(existingAnalysisRows.map((analysis) => analysis.elderId));
const missingAnalyses = DEFAULT_PREPARED_AI_ANALYSES.filter((analysis) => {
const elderId = elderIdByName.get(analysis.elderName);
return elderId !== undefined && !analyzedElderIds.has(elderId);
});
if (missingKnowledgeEntries.length === 0 && missingAnalyses.length === 0) {
return;
}
const now = new Date();
await database.transaction(async (transaction) => {
if (missingKnowledgeEntries.length > 0) {
const knowledgeRows = await transaction
.insert(aiKnowledgeEntries)
.values(
missingKnowledgeEntries.map((entry) => ({
scope: entry.scope,
organizationId: entry.scope === "organization" ? organizationId : null,
title: entry.title,
category: entry.category,
tags: entry.tags,
body: entry.body,
status: entry.status,
createdAt: now,
updatedAt: now,
})),
)
.returning();
if (knowledgeRows.length !== missingKnowledgeEntries.length) {
throw new Error("AI 知识库初始化失败");
}
const sourceByKey = new Map(missingKnowledgeEntries.map((entry) => [getSeedKnowledgeKey(entry), entry]));
await transaction.insert(aiKnowledgeChunks).values(
knowledgeRows.map((row) => {
const source = sourceByKey.get(getSeedKnowledgeKey(row));
if (!source) {
throw new Error("AI 知识库分块初始化失败");
}
return {
entryId: row.id,
organizationId: row.organizationId,
scope: row.scope,
chunkIndex: 0,
content: buildSeedKnowledgeContent(source),
embedding: [],
sourceTitle: row.title,
sourceCategory: row.category,
createdAt: now,
};
}),
);
}
if (missingAnalyses.length > 0) {
await transaction.insert(elderAiAnalyses).values(
missingAnalyses.map((analysis) => {
const elderId = elderIdByName.get(analysis.elderName);
if (!elderId) {
throw new Error("AI 分析记录初始化失败");
}
return {
organizationId,
elderId,
status: "completed" as const,
dataScopes: analysis.dataScopes,
resultJson: analysis.result,
citationsJson: analysis.result.citations,
modelSummaryJson: analysis.result.modelSummary,
createdAt: new Date(now.getTime() - analysis.createdHoursAgo * 60 * 60 * 1000),
};
}),
);
}
});
}
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 {
return rows.length > 0;
}
@@ -1150,6 +1789,8 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
]);
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
await seedDefaultCollaborationWorkspaceData(organizationId);
await seedDefaultAiWorkspaceData(organizationId);
return;
}
@@ -1608,4 +2249,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
}),
);
});
await seedDefaultCollaborationWorkspaceData(organizationId);
await seedDefaultAiWorkspaceData(organizationId);
}

View File

@@ -45,6 +45,10 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
{ id: "elder:create", label: "新增老人档案", category: "老人", description: "创建老人档案。" },
{ id: "elder:update", label: "更新老人档案", category: "老人", description: "更新老人档案和照护信息。" },
{ id: "elder:delete", label: "删除老人档案", category: "老人", description: "删除老人档案。" },
{ id: "ai:read", label: "查看 AI 分析", category: "AI", description: "查看并生成授权数据范围内的 AI 分析。" },
{ id: "ai:manage", label: "管理 AI 能力", category: "AI", description: "预留给 AI 配置与分析治理能力,当前不开放独立管理入口。" },
{ id: "knowledge:read", label: "查看知识库", category: "AI", description: "查看并检索平台和机构知识库内容。" },
{ id: "knowledge:manage", label: "管理知识库", category: "AI", description: "维护平台或机构知识库条目。" },
];
type SeedRole = {
@@ -121,6 +125,10 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"elder:create",
"elder:update",
"elder:delete",
"ai:read",
"ai:manage",
"knowledge:read",
"knowledge:manage",
],
},
{
@@ -151,6 +159,10 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"elder:create",
"elder:update",
"elder:delete",
"ai:read",
"ai:manage",
"knowledge:read",
"knowledge:manage",
],
},
{
@@ -169,6 +181,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"admission:read",
"elder:read",
"elder:update",
"ai:read",
"knowledge:read",
],
},
{

View File

@@ -1,7 +1,9 @@
import { sql } from "drizzle-orm";
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
@@ -41,6 +43,9 @@ export const familyContactStatusEnum = pgEnum("family_contact_status", ["active"
export const familyVisitStatusEnum = pgEnum("family_visit_status", ["requested", "approved", "completed", "cancelled"]);
export const familyFeedbackTypeEnum = pgEnum("family_feedback_type", ["service", "care", "meal", "environment", "other"]);
export const familyFeedbackStatusEnum = pgEnum("family_feedback_status", ["open", "in_progress", "resolved", "closed"]);
export const aiKnowledgeScopeEnum = pgEnum("ai_knowledge_scope", ["platform", "organization"]);
export const aiKnowledgeStatusEnum = pgEnum("ai_knowledge_status", ["enabled", "disabled"]);
export const elderAiAnalysisStatusEnum = pgEnum("elder_ai_analysis_status", ["completed", "failed"]);
export const systemSettings = pgTable("system_settings", {
id: text("id").primaryKey().default("global"),
@@ -168,6 +173,8 @@ export const organizationInvitations = pgTable("organization_invitations", {
acceptedByAccountId: uuid("accepted_by_account_id").references(() => accounts.id),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
maxUses: integer("max_uses").notNull().default(1),
usedCount: integer("used_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
@@ -519,3 +526,55 @@ export const systemIncidents = pgTable("system_incidents", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const aiKnowledgeEntries = pgTable("ai_knowledge_entries", {
id: uuid("id").primaryKey().defaultRandom(),
scope: aiKnowledgeScopeEnum("scope").notNull(),
organizationId: uuid("organization_id").references(() => organizations.id, { onDelete: "cascade" }),
title: text("title").notNull(),
category: text("category").notNull().default(""),
tags: text("tags").notNull().default(""),
body: text("body").notNull(),
status: aiKnowledgeStatusEnum("status").notNull().default("enabled"),
createdByAccountId: uuid("created_by_account_id").references(() => accounts.id, { onDelete: "set null" }),
updatedByAccountId: uuid("updated_by_account_id").references(() => accounts.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
scopeLookup: index("ai_knowledge_entries_scope_lookup").on(table.scope, table.status),
organizationLookup: index("ai_knowledge_entries_organization_lookup").on(table.organizationId, table.status),
}));
export const aiKnowledgeChunks = pgTable("ai_knowledge_chunks", {
id: uuid("id").primaryKey().defaultRandom(),
entryId: uuid("entry_id").notNull().references(() => aiKnowledgeEntries.id, { onDelete: "cascade" }),
organizationId: uuid("organization_id").references(() => organizations.id, { onDelete: "cascade" }),
scope: aiKnowledgeScopeEnum("scope").notNull(),
chunkIndex: integer("chunk_index").notNull(),
content: text("content").notNull(),
embedding: jsonb("embedding").$type<number[]>().notNull().default(sql`'[]'::jsonb`),
sourceTitle: text("source_title").notNull(),
sourceCategory: text("source_category").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
entryLookup: index("ai_knowledge_chunks_entry_lookup").on(table.entryId),
scopeLookup: index("ai_knowledge_chunks_scope_lookup").on(table.scope, table.organizationId),
}));
export const elderAiAnalyses = pgTable("elder_ai_analyses", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
actorAccountId: uuid("actor_account_id").references(() => accounts.id, { onDelete: "set null" }),
status: elderAiAnalysisStatusEnum("status").notNull(),
dataScopes: jsonb("data_scopes").$type<string[]>().notNull().default(sql`'[]'::jsonb`),
resultJson: jsonb("result_json"),
citationsJson: jsonb("citations_json").notNull().default(sql`'[]'::jsonb`),
modelSummaryJson: jsonb("model_summary_json").notNull().default(sql`'{}'::jsonb`),
errorCategory: text("error_category").notNull().default(""),
errorReason: text("error_reason").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
elderLookup: index("elder_ai_analyses_elder_lookup").on(table.organizationId, table.elderId, table.createdAt),
statusLookup: index("elder_ai_analyses_status_lookup").on(table.organizationId, table.status),
}));

View File

@@ -113,6 +113,8 @@ function toOrganizationInvitation(row: {
acceptedByAccountId: row.invitation.acceptedByAccountId ?? undefined,
acceptedAt: row.invitation.acceptedAt ? iso(row.invitation.acceptedAt) : undefined,
expiresAt: iso(row.invitation.expiresAt),
maxUses: row.invitation.maxUses,
usedCount: row.invitation.usedCount,
createdAt: iso(row.invitation.createdAt),
updatedAt: iso(row.invitation.updatedAt),
};

View File

@@ -210,6 +210,8 @@ export async function readData(): Promise<AppData> {
acceptedByAccountId: row.invitation.acceptedByAccountId ?? undefined,
acceptedAt: row.invitation.acceptedAt ? iso(row.invitation.acceptedAt) : undefined,
expiresAt: iso(row.invitation.expiresAt),
maxUses: row.invitation.maxUses,
usedCount: row.invitation.usedCount,
createdAt: iso(row.invitation.createdAt),
updatedAt: iso(row.invitation.updatedAt),
}));

View File

@@ -59,6 +59,10 @@ export const PERMISSIONS = [
"elder:create",
"elder:update",
"elder:delete",
"ai:read",
"ai:manage",
"knowledge:read",
"knowledge:manage",
] as const;
export type Permission = (typeof PERMISSIONS)[number];
@@ -156,6 +160,8 @@ export type OrganizationInvitation = {
acceptedByAccountId?: string;
acceptedAt?: string;
expiresAt: string;
maxUses: number;
usedCount: number;
createdAt: string;
updatedAt: string;
};

View File

@@ -1,23 +1,31 @@
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 { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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 { CareTaskPriority, CareTaskStatus } 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 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 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 { 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 = {
label: string;
value: 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 = {
@@ -56,26 +64,82 @@ export type DashboardHealthReview = {
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 = {
alertTriggers?: DashboardAlertTrigger[];
alertsHref: string;
aiBoardItems?: ElderAiAnalysisBoardItem[];
bedsHref: string;
careHref: string;
careTasks: DashboardCareTask[];
careTasks?: DashboardCareTask[];
deviceTickets?: DashboardDeviceTicket[];
devicesHref: string;
eldersHref: string;
emergencyHref: string;
emergencyIncidents: DashboardIncident[];
emergencyIncidents?: DashboardIncident[];
familyFeedback?: DashboardFamilyFeedback[];
familyHref: string;
familyVisits?: DashboardFamilyVisit[];
healthHref: string;
healthReviews: DashboardHealthReview[];
healthReviews?: DashboardHealthReview[];
metrics: DashboardMetric[];
occupancyData: BedOccupancyDatum[];
recentAdmissions: DashboardAdmission[];
incidents: DashboardIncident[];
notices?: DashboardNotice[];
noticesHref: string;
occupancyData?: BedOccupancyDatum[];
recentAdmissions?: DashboardAdmission[];
incidents?: DashboardIncident[];
};
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
admissions: CheckCircle2,
ai: BrainCircuit,
alerts: ShieldAlert,
beds: BedDouble,
care: ListChecks,
devices: TabletSmartphone,
family: Users,
health: HeartPulse,
incidents: HeartPulse,
notices: Megaphone,
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({
alertTriggers,
alertsHref,
aiBoardItems,
bedsHref,
careHref,
careTasks,
deviceTickets,
devicesHref,
eldersHref,
emergencyHref,
emergencyIncidents,
familyFeedback,
familyHref,
familyVisits,
healthHref,
healthReviews,
metrics,
notices,
noticesHref,
occupancyData,
recentAdmissions,
incidents,
}: 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 (
<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">
@@ -148,7 +265,9 @@ export function DashboardHome({
})}
</section>
{hasCoreQueues ? (
<section className="grid gap-4 xl:grid-cols-3">
{careTasks !== undefined ? (
<QueueCard
emptyText="暂无待处理护理任务"
href={careHref}
@@ -162,6 +281,8 @@ export function DashboardHome({
}))}
title="护理待办"
/>
) : null}
{healthReviews !== undefined ? (
<QueueCard
emptyText="暂无待复核健康异常"
href={healthHref}
@@ -175,6 +296,8 @@ export function DashboardHome({
}))}
title="健康复核"
/>
) : null}
{emergencyIncidents !== undefined ? (
<QueueCard
emptyText="暂无待处理应急事件"
href={emergencyHref}
@@ -188,9 +311,71 @@ export function DashboardHome({
}))}
title="安全应急"
/>
) : null}
</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]">
{occupancyData !== undefined ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -207,7 +392,9 @@ export function DashboardHome({
</Link>
</CardContent>
</Card>
) : null}
{recentAdmissions !== undefined ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
@@ -250,8 +437,52 @@ export function DashboardHome({
</div>
</CardContent>
</Card>
) : null}
</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></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>
<CardHeader>
<CardTitle></CardTitle>
@@ -274,6 +505,7 @@ export function DashboardHome({
{incidents.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
) : null}
</div>
);
}
@@ -284,7 +516,7 @@ type QueueItem = {
detail: string;
meta: string;
title: string;
variant: "danger" | "secondary" | "warning";
variant: "danger" | "secondary" | "success" | "warning";
};
function QueueCard({

View File

@@ -1,7 +1,7 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { Edit3, Filter, Plus, Search, Trash2 } from "lucide-react";
import { Brain, Edit3, Filter, Plus, Search, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -10,6 +10,7 @@ import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import { ElderAiAnalysisDialog } from "@/modules/ai/components/ElderAiAnalysisDialog";
import type { ApiResult } from "@/modules/core/server/api";
import type { Permission } from "@/modules/core/types";
import type { CareLevel, Elder, ElderInput, ElderStatus } from "@/modules/elders/types";
@@ -123,10 +124,12 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
const [statusFilter, setStatusFilter] = useState<ElderStatus | typeof ALL_STATUS>(ALL_STATUS);
const [careLevelFilter, setCareLevelFilter] = useState<CareLevel | typeof ALL_CARE_LEVELS>(ALL_CARE_LEVELS);
const [page, setPage] = useState(1);
const [aiTarget, setAiTarget] = useState<Elder | null>(null);
const canCreate = permissions.includes("elder:create");
const canUpdate = permissions.includes("elder:update");
const canDelete = permissions.includes("elder:delete");
const canUseAi = permissions.includes("ai:read") && permissions.includes("elder:read");
const isEditing = editingId !== null;
const canSubmit = isEditing ? canUpdate : canCreate;
@@ -332,6 +335,7 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="搜索老人档案"
className="pl-9"
onChange={(event) => updateQuery(event.target.value)}
placeholder="搜索姓名、床位、联系人"
@@ -401,6 +405,17 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
{canUseAi ? (
<Button
onClick={() => setAiTarget(elder)}
size="sm"
type="button"
variant="outline"
>
<Brain className="size-4" aria-hidden="true" />
</Button>
) : null}
<Button
disabled={!canUpdate}
onClick={() => beginEdit(elder)}
@@ -581,6 +596,8 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
</div>
</div>
</Dialog>
<ElderAiAnalysisDialog elder={aiTarget} onClose={() => setAiTarget(null)} open={aiTarget !== null} />
</div>
);
}

View File

@@ -1,13 +1,52 @@
import { redirect } from "next/navigation";
import { HeartPulse } from "lucide-react";
import { Activity, FileText, HeartPulse, ShieldAlert, Stethoscope } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table } from "@/components/ui/table";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { hasPermission } from "@/modules/core/server/permissions";
import { HealthAdminClient } from "@/modules/health/components/HealthAdminClient";
import { listHealthAdminData } from "@/modules/health/server/operations";
import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types";
import {
CHRONIC_CONDITION_STATUS_LABELS,
HEALTH_REVIEW_SEVERITY_LABELS,
HEALTH_REVIEW_STATUS_LABELS,
VITAL_SOURCE_LABELS,
} from "@/modules/health/types";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export async function renderHealthWorkspacePage(): Promise<React.ReactElement> {
type HealthPageData = {
canManage: boolean;
data: Awaited<ReturnType<typeof listHealthAdminData>>;
};
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function formatTenths(value: number | undefined, suffix = ""): string {
return value === undefined ? "-" : `${(value / 10).toFixed(1)}${suffix}`;
}
function severityBadgeVariant(severity: HealthReviewSeverity): "secondary" | "warning" | "danger" {
if (severity === "critical") {
return "danger";
}
return severity === "warning" ? "warning" : "secondary";
}
function reviewStatusBadgeVariant(status: HealthReviewStatus): "success" | "secondary" | "warning" {
if (status === "pending") {
return "warning";
}
return status === "resolved" ? "success" : "secondary";
}
async function loadHealthPageData(): Promise<HealthPageData> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
@@ -23,6 +62,17 @@ export async function renderHealthWorkspacePage(): Promise<React.ReactElement> {
}
const data = await listHealthAdminData(organizationId);
return {
canManage: hasPermission(context.permissions, "health:manage"),
data,
};
}
export async function renderHealthWorkspacePage(): Promise<React.ReactElement> {
const { data } = await loadHealthPageData();
const pendingReviews = data.reviews.filter((review) => review.status === "pending").slice(0, 8);
const recentVitals = data.vitals.slice(0, 8);
const activeConditions = data.chronicConditions.filter((condition) => condition.status === "active").slice(0, 8);
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
@@ -33,12 +83,172 @@ export async function renderHealthWorkspacePage(): Promise<React.ReactElement> {
</span>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold tracking-normal"></h1>
<p className="mt-1 truncate text-sm text-muted-foreground"></p>
<p className="mt-1 truncate text-sm text-muted-foreground"></p>
</div>
</div>
</section>
<HealthAdminClient canManage={hasPermission(context.permissions, "health:manage")} initialData={data} />
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={<FileText className="size-4" aria-hidden="true" />} label="健康档案" value={data.metrics.eldersWithProfiles} />
<MetricCard icon={<Activity className="size-4" aria-hidden="true" />} label="今日体征" value={data.metrics.vitalsToday} />
<MetricCard icon={<Stethoscope className="size-4" aria-hidden="true" />} label="慢病管理" value={data.metrics.activeChronicConditions} />
<MetricCard icon={<ShieldAlert className="size-4" aria-hidden="true" />} label="待复核" value={data.metrics.pendingReviews} />
</section>
<section className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[720px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{pendingReviews.map((review) => (
<Table.Row key={review.id}>
<Table.Cell className="px-4 py-3 font-medium">{review.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{review.title}</p>
<p className="text-xs text-muted-foreground">{review.description}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={severityBadgeVariant(review.severity)}>
{HEALTH_REVIEW_SEVERITY_LABELS[review.severity]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={reviewStatusBadgeVariant(review.status)}>
{HEALTH_REVIEW_STATUS_LABELS[review.status]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(review.createdAt)}</Table.Cell>
</Table.Row>
))}
{pendingReviews.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid gap-2">
{activeConditions.map((condition) => (
<div className="rounded-md border p-3" key={condition.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{condition.elderName}</p>
<p className="mt-1 text-xs text-muted-foreground">{condition.name}</p>
</div>
<Badge variant="warning">{CHRONIC_CONDITION_STATUS_LABELS[condition.status]}</Badge>
</div>
<p className="mt-2 text-xs text-muted-foreground">{condition.followUpNotes || condition.treatmentNotes || "暂无随访备注"}</p>
</div>
))}
{activeConditions.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[820px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{recentVitals.map((vital) => (
<Table.Row key={vital.id}>
<Table.Cell className="px-4 py-3 font-medium">{vital.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(vital.recordedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">{VITAL_SOURCE_LABELS[vital.source]}</Table.Cell>
<Table.Cell className="px-4 py-3">
{vital.systolicBp && vital.diastolicBp ? `${vital.systolicBp}/${vital.diastolicBp}` : "-"}
</Table.Cell>
<Table.Cell className="px-4 py-3">{vital.heartRate?.toString() ?? "-"}</Table.Cell>
<Table.Cell className="px-4 py-3">{formatTenths(vital.temperatureTenths, "°C")}</Table.Cell>
<Table.Cell className="px-4 py-3">{vital.spo2 === undefined ? "-" : `${vital.spo2}%`}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{vital.notes || "-"}</Table.Cell>
</Table.Row>
))}
{recentVitals.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={8}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
</div>
);
}
export async function renderHealthSettingsPage(): Promise<React.ReactElement> {
const { canManage, data } = await loadHealthPageData();
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex flex-col gap-3 border-b pb-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="inline-flex size-9 items-center justify-center rounded-md bg-secondary text-primary">
<HeartPulse className="size-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold tracking-normal"></h1>
<p className="mt-1 truncate text-sm text-muted-foreground"></p>
</div>
</div>
</section>
<HealthAdminClient canManage={canManage} initialData={data} />
</div>
);
}
function MetricCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardDescription>{label}</CardDescription>
<span className="text-primary">{icon}</span>
</CardHeader>
<CardContent>
<CardTitle className="text-3xl">{value}</CardTitle>
</CardContent>
</Card>
);
}

View File

@@ -167,7 +167,7 @@ export function GlobalSettingsClient({
<h3 className="text-sm font-semibold"></h3>
</div>
<p className="mt-2 text-sm text-muted-foreground">
`/register?invite=...`
`/register?invite=...`使
</p>
</section>
<section className="rounded-md border p-3">
@@ -183,7 +183,7 @@ export function GlobalSettingsClient({
<div className="flex flex-col gap-3 rounded-md border bg-secondary/25 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-sm">
<p className="font-medium"></p>
<p className="mt-1 text-muted-foreground"></p>
<p className="mt-1 text-muted-foreground">使</p>
</div>
<LinkButton href={organizationSettingsHref} variant="outline">
<UserPlus className="size-4" aria-hidden="true" />

View File

@@ -179,12 +179,13 @@ export function OrganizationDetailClient({
</CardHeader>
<CardContent className="grid gap-4">
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[720px] text-sm">
<Table className="w-full min-w-[820px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium">使</Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
@@ -197,6 +198,9 @@ export function OrganizationDetailClient({
<Table.Cell className="px-4 py-3">
<Badge variant={invitation.status === "active" ? "success" : "secondary"}>{invitation.status}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{invitation.usedCount} / {invitation.maxUses}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{new Date(invitation.expiresAt).toLocaleString("zh-CN")}
</Table.Cell>
@@ -215,7 +219,7 @@ export function OrganizationDetailClient({
))}
{invitations.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
@@ -225,7 +229,7 @@ export function OrganizationDetailClient({
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LinkIcon className="size-4" aria-hidden="true" />
使 `/register?invite=...`
使 `/register?invite=...`使
</div>
</CardContent>
</Card>

View File

@@ -8,6 +8,14 @@ import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
DEFAULT_INVITATION_MAX_USES,
DEFAULT_INVITATION_VALIDITY_DAYS,
MAX_INVITATION_MAX_USES,
MAX_INVITATION_VALIDITY_DAYS,
MIN_INVITATION_MAX_USES,
MIN_INVITATION_VALIDITY_DAYS,
} from "@/modules/core/invitation-limits";
import type { ApiResult } from "@/modules/core/server/api";
import type { Organization, OrganizationInvitation, RoleDefinition } from "@/modules/core/types";
@@ -64,7 +72,9 @@ export function OrganizationInviteDialog({
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: String(formData.get("email") ?? ""),
maxUses: Number(formData.get("maxUses") ?? DEFAULT_INVITATION_MAX_USES),
roleId,
validityDays: Number(formData.get("validityDays") ?? DEFAULT_INVITATION_VALIDITY_DAYS),
}),
});
const result = (await response.json()) as ApiResult<{ invitation: OrganizationInvitation }>;
@@ -84,7 +94,7 @@ export function OrganizationInviteDialog({
return (
<Dialog
description={`生成 ${organization.name} 的邀请链接,可限定邮箱并指定入组角色。`}
description={`生成 ${organization.name} 的邀请链接,可限定邮箱入组角色、有效期和使用次数`}
onClose={closeDialog}
open={open}
title="发起机构邀请"
@@ -109,6 +119,26 @@ export function OrganizationInviteDialog({
required
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<Input
defaultValue={DEFAULT_INVITATION_VALIDITY_DAYS}
label="有效期(天)"
max={MAX_INVITATION_VALIDITY_DAYS}
min={MIN_INVITATION_VALIDITY_DAYS}
name="validityDays"
required
type="number"
/>
<Input
defaultValue={DEFAULT_INVITATION_MAX_USES}
label="最大使用次数"
max={MAX_INVITATION_MAX_USES}
min={MIN_INVITATION_MAX_USES}
name="maxUses"
required
type="number"
/>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}

View File

@@ -6,6 +6,7 @@ import { SignOutButton } from "@/modules/auth/components/SignOutButton";
import { AccountMenu } from "@/modules/shared/components/AccountMenu";
import { AppBreadcrumbs } from "@/modules/shared/components/AppBreadcrumbs";
import { AppSidebarNav } from "@/modules/shared/components/AppSidebarNav";
import { ThemeToggle } from "@/modules/shared/components/ThemeToggle";
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
import { navItems } from "@/modules/shared/lib/navigation";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
@@ -30,7 +31,7 @@ export function AppShell({
return (
<div className="flex h-svh items-stretch overflow-hidden bg-background text-foreground">
<aside className="hidden h-svh w-64 shrink-0 border-r bg-white/78 backdrop-blur xl:block">
<aside className="hidden h-svh w-64 shrink-0 border-r bg-card/78 backdrop-blur xl:block">
<div className="flex h-full min-h-0 flex-col">
<Link href={dashboardHref} className="flex h-16 shrink-0 items-center gap-3 border-b px-5">
<span className="inline-flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
@@ -61,16 +62,19 @@ export function AppShell({
</span>
<AppBreadcrumbs />
</div>
<div className="flex min-w-0 items-center gap-2 xl:hidden">
<div className="hidden min-w-0 items-center gap-2 sm:flex">
<div className="flex min-w-0 items-center gap-2">
<ThemeToggle />
<div className="hidden min-w-0 items-center gap-2 sm:flex xl:hidden">
<p className="min-w-0 max-w-[min(52vw,24rem)] truncate whitespace-nowrap text-right text-sm leading-tight">
<span className="font-medium">{account.name}</span>
<span className="ml-2 text-muted-foreground">{account.email}</span>
</p>
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
</div>
<div className="xl:hidden">
<SignOutButton />
</div>
</div>
</header>
<main className="page-enter min-h-0 flex-1 overflow-y-auto px-4 py-5 md:px-8">{children}</main>

View File

@@ -5,13 +5,16 @@ import { usePathname } from "next/navigation";
import {
BedDouble,
Bell,
BookOpenText,
Building2,
ClipboardCheck,
Globe2,
HeartPulse,
LayoutDashboard,
ListChecks,
LockKeyhole,
Radio,
ReceiptText,
Settings,
ShieldAlert,
TabletSmartphone,
@@ -30,6 +33,7 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
alerts: Radio,
audit: ListChecks,
beds: BedDouble,
billing: ReceiptText,
care: ClipboardCheck,
dashboard: LayoutDashboard,
devices: TabletSmartphone,
@@ -37,6 +41,7 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
family: UserRoundCheck,
global: Globe2,
health: HeartPulse,
knowledge: BookOpenText,
notices: Bell,
organizations: Building2,
roles: Settings,
@@ -49,7 +54,15 @@ type AppSidebarNavProps = {
permissions: Permission[];
};
type SidebarNavItem = NavItem & {
canAccess: boolean;
};
function canViewNavItem(item: NavItem, permissions: readonly Permission[]): boolean {
if (permissions.includes("platform:manage")) {
return true;
}
if (item.permission && !permissions.includes(item.permission)) {
return false;
}
@@ -63,12 +76,13 @@ function canViewNavItem(item: NavItem, permissions: readonly Permission[]): bool
export function AppSidebarNav({ organizationSlug, permissions }: AppSidebarNavProps): React.ReactElement {
const pathname = usePathname();
const visibleNavGroups = navGroups
.map((group) => ({
const visibleNavGroups = navGroups.map((group): { label: string; items: SidebarNavItem[] } => ({
...group,
items: group.items.filter((item) => canViewNavItem(item, permissions)),
}))
.filter((group) => group.items.length > 0);
items: group.items.map((item) => ({
...item,
canAccess: canViewNavItem(item, permissions),
})),
}));
return (
<nav className="min-h-0 flex-1 overflow-y-auto px-3 py-4" aria-label="主导航">
@@ -80,10 +94,11 @@ export function AppSidebarNav({ organizationSlug, permissions }: AppSidebarNavPr
{group.items.map((item) => {
const Icon = iconMap[item.icon];
const href = getWorkspaceHref(organizationSlug, item.path);
const isActive = isWorkspacePathActive(pathname, item.path);
const isActive = item.canAccess && isWorkspacePathActive(pathname, item.path);
return (
<li key={item.path}>
{item.canAccess ? (
<Link
href={href}
aria-current={isActive ? "page" : undefined}
@@ -103,6 +118,18 @@ export function AppSidebarNav({ organizationSlug, permissions }: AppSidebarNavPr
/>
<span className="min-w-0 truncate font-medium">{item.label}</span>
</Link>
) : (
<span
className="flex min-h-11 cursor-not-allowed items-center gap-3 rounded-md px-3 text-sm text-muted-foreground/70"
aria-disabled="true"
title="当前角色无权访问"
>
<Icon className="size-4 shrink-0 text-muted-foreground/60" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate font-medium">{item.label}</span>
<LockKeyhole className="size-3.5 shrink-0 text-muted-foreground/55" aria-hidden="true" />
<span className="sr-only">访</span>
</span>
)}
</li>
);
})}

View File

@@ -1,52 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { Database } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type ModulePageProps = {
title: string;
eyebrow: string;
description: string;
icon: LucideIcon;
phase: "待接入" | "二期预留" | "三期预留";
};
export function ModulePage({ title, eyebrow, description, icon: Icon, phase }: ModulePageProps): React.ReactElement {
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<section className="flex flex-col gap-3 border-b pb-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="inline-flex size-9 items-center justify-center rounded-md bg-secondary text-primary">
<Icon className="size-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-xl font-semibold tracking-normal">{title}</h1>
<Badge variant="secondary">{phase}</Badge>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">{eyebrow}</p>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
</section>
<section>
<Card className="max-w-3xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Database className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Drizzle API
</p>
</CardContent>
</Card>
</section>
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { Bell, Radio, TabletSmartphone, Wrench } from "lucide-react";
import { ModulePage } from "@/modules/shared/components/ModulePage";
export function DevicesModulePage(): React.ReactElement {
return (
<ModulePage
title="设备运维"
eyebrow="设备台账与维修工单"
description="待接入设备台账、故障上报、维修派单、处理记录和状态恢复数据源。"
icon={Wrench}
phase="待接入"
/>
);
}
export function FamilyModulePage(): React.ReactElement {
return (
<ModulePage
title="家属服务"
eyebrow="家属端与探访反馈"
description="二期计划接入家属授权、老人动态、探访预约和服务反馈数据源。"
icon={TabletSmartphone}
phase="二期预留"
/>
);
}
export function NoticesModulePage(): React.ReactElement {
return (
<ModulePage
title="公告通知"
eyebrow="公告发布与阅读状态"
description="待接入公告草稿、发布范围、阅读状态和通知记录数据源。"
icon={Bell}
phase="待接入"
/>
);
}
export function AlertsModulePage(): React.ReactElement {
return (
<ModulePage
title="规则预警"
eyebrow="规则中心与风险识别"
description="三期计划接入健康阈值、任务逾期、设备故障、应急事件和风险老人识别规则。"
icon={Radio}
phase="三期预留"
/>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { Moon, Monitor, Sun } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
type ThemeMode = "light" | "dark" | "system";
const storageKey = "teatea-theme";
const themeOptions: Array<{
mode: ThemeMode;
label: string;
icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
}> = [
{ mode: "light", label: "浅色主题", icon: Sun },
{ mode: "dark", label: "深色主题", icon: Moon },
{ mode: "system", label: "跟随系统", icon: Monitor },
];
function getStoredTheme(): ThemeMode {
if (typeof window === "undefined") {
return "system";
}
const storedTheme = window.localStorage.getItem(storageKey);
return storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" ? storedTheme : "system";
}
function applyTheme(mode: ThemeMode): void {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", mode === "dark" || (mode === "system" && prefersDark));
document.documentElement.style.colorScheme = mode === "dark" || (mode === "system" && prefersDark) ? "dark" : "light";
}
export function ThemeToggle(): React.ReactElement {
const [theme, setTheme] = useState<ThemeMode>("system");
useEffect(() => {
const storedTheme = getStoredTheme();
setTheme(storedTheme);
applyTheme(storedTheme);
const media = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (): void => {
if (getStoredTheme() === "system") {
applyTheme("system");
}
};
media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange);
}, []);
function handleThemeChange(nextTheme: ThemeMode): void {
window.localStorage.setItem(storageKey, nextTheme);
setTheme(nextTheme);
applyTheme(nextTheme);
}
return (
<div
aria-label="主题切换"
className="inline-flex h-10 shrink-0 items-center rounded-md border bg-card p-1 shadow-sm"
role="group"
>
{themeOptions.map((option) => {
const Icon = option.icon;
const isSelected = theme === option.mode;
return (
<button
aria-label={option.label}
aria-pressed={isSelected}
className={cn(
"inline-flex size-8 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isSelected && "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
)}
key={option.mode}
onClick={() => handleThemeChange(option.mode)}
title={option.label}
type="button"
>
<Icon className="size-4" aria-hidden={true} />
</button>
);
})}
</div>
);
}

View File

@@ -4,6 +4,7 @@ export type NavIconKey =
| "alerts"
| "audit"
| "beds"
| "billing"
| "care"
| "dashboard"
| "devices"
@@ -11,6 +12,7 @@ export type NavIconKey =
| "family"
| "global"
| "health"
| "knowledge"
| "notices"
| "organizations"
| "roles"
@@ -38,7 +40,7 @@ export const navGroups: NavGroup[] = [
path: "/dashboard",
label: "运营看板",
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",
@@ -52,6 +54,12 @@ export const navGroups: NavGroup[] = [
icon: "beds",
anyPermissions: ["facility:read", "admission:read"],
},
{
path: "/billing",
label: "费用管理",
icon: "billing",
permission: "admission:manage",
},
{
path: "/health",
label: "健康照护",
@@ -134,6 +142,12 @@ export const navGroups: NavGroup[] = [
icon: "health",
permission: "health:read",
},
{
path: "/settings/knowledge",
label: "知识库",
icon: "knowledge",
permission: "knowledge:read",
},
{
path: "/settings/audit",
label: "审计日志",

View File

@@ -4,6 +4,7 @@ export const WORKSPACE_SECTION_KEYS = [
"dashboard",
"elders",
"beds",
"billing",
"health",
"care",
"emergency",

View File

@@ -15,11 +15,14 @@
},
"dependencies": {
"@cloudflare/kumo": "^2.6.0",
"@langchain/core": "^1.2.1",
"@langchain/openai": "^1.5.3",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
"langchain": "^1.5.2",
"lucide-react": "^0.468.0",
"next": "^15.0.0",
"postgres": "^3.4.9",

277
pnpm-lock.yaml generated
View File

@@ -10,7 +10,13 @@ importers:
dependencies:
'@cloudflare/kumo':
specifier: ^2.6.0
version: 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)
'@langchain/core':
specifier: ^1.2.1
version: 1.2.1(openai@6.45.0(zod@4.4.3))
'@langchain/openai':
specifier: ^1.5.3
version: 1.5.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -26,6 +32,9 @@ importers:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(postgres@3.4.9)
langchain:
specifier: ^1.5.2
version: 1.5.2(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(openai@6.45.0(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.7)
@@ -125,6 +134,9 @@ packages:
'@types/react':
optional: true
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
'@cloudflare/kumo@2.6.0':
resolution: {integrity: sha512-rcUUvhrtxI0veNJZLgKVSnxH/L0M48jtc4UoNhvu0l0RiRmjHORFTBYq/phFQyt7JGG3s98QPZc5w1eW+UQIOg==}
hasBin: true
@@ -842,6 +854,50 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@langchain/core@1.2.1':
resolution: {integrity: sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g==}
engines: {node: '>=20'}
'@langchain/langgraph-checkpoint@1.1.3':
resolution: {integrity: sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.1.48
'@langchain/langgraph-sdk@1.9.25':
resolution: {integrity: sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==}
peerDependencies:
'@langchain/core': ^1.1.48
react: ^18 || ^19
react-dom: ^18 || ^19
svelte: ^4.0.0 || ^5.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
svelte:
optional: true
vue:
optional: true
'@langchain/langgraph@1.4.7':
resolution: {integrity: sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.1.48
zod: ^3.25.32 || ^4.2.0
'@langchain/openai@1.5.3':
resolution: {integrity: sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==}
engines: {node: '>=20'}
peerDependencies:
'@langchain/core': ^1.2.1
'@langchain/protocol@0.0.18':
resolution: {integrity: sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@@ -1542,6 +1598,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
@@ -2027,6 +2086,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
@@ -2279,6 +2341,10 @@ packages:
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
engines: {node: '>= 0.4'}
is-network-error@1.3.2:
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
engines: {node: '>=16'}
is-number-object@1.1.1:
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
engines: {node: '>= 0.4'}
@@ -2337,6 +2403,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2364,6 +2433,32 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
langchain@1.5.2:
resolution: {integrity: sha512-5vCWYvzxuY7gJ8UCgSZ17SM45gou5PtRguFgeQIyCnHzGZQUFLHKi/eQArL3Ad98fJ/UiOEAaTXiI3jfIdoABg==}
engines: {node: '>=20'}
peerDependencies:
'@langchain/core': ^1.2.1
langsmith@0.7.15:
resolution: {integrity: sha512-huRfzLKcREE+ABkqKEriXK8Ax9V+xuV3d3x4PINEGi+hi4qyTvB4Nc2dpLSyfW/Ioj6+6d7T8majjWCe7mXc8A==}
peerDependencies:
'@opentelemetry/api': '*'
'@opentelemetry/exporter-trace-otlp-proto': '*'
'@opentelemetry/sdk-trace-base': '*'
openai: '*'
ws: '>=7'
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@opentelemetry/exporter-trace-otlp-proto':
optional: true
'@opentelemetry/sdk-trace-base':
optional: true
openai:
optional: true
ws:
optional: true
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -2527,6 +2622,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
nanoid@3.3.15:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2607,6 +2706,26 @@ packages:
oniguruma-to-es@4.3.6:
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
openai@6.45.0:
resolution: {integrity: sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==}
peerDependencies:
'@aws-sdk/credential-provider-node': '>=3.972.0 <4'
'@smithy/hash-node': '>=4.3.0 <5'
'@smithy/signature-v4': '>=5.4.0 <6'
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
'@aws-sdk/credential-provider-node':
optional: true
'@smithy/hash-node':
optional: true
'@smithy/signature-v4':
optional: true
ws:
optional: true
zod:
optional: true
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2615,6 +2734,10 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -2623,6 +2746,26 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-queue@6.6.2:
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
engines: {node: '>=8'}
p-queue@9.3.1:
resolution: {integrity: sha512-POWdiIPmsUPGwb4FeQ4OBg46aqmcInSWe45CKDsGHiOBiVQM9chqfQTuqhuTzcg2Vz9faTI65at0KkVyVEiCHw==}
engines: {node: '>=20'}
p-retry@7.1.1:
resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==}
engines: {node: '>=20'}
p-timeout@3.2.0:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
p-timeout@7.0.1:
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
engines: {node: '>=20'}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -3172,6 +3315,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -3206,7 +3352,9 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@cloudflare/kumo@2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@cfworker/json-schema@4.1.1': {}
'@cloudflare/kumo@2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)':
dependencies:
'@base-ui/react': 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@phosphor-icons/react': 2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -3219,6 +3367,8 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
shiki: 4.3.0
tailwind-merge: 3.6.0
optionalDependencies:
zod: 4.4.3
transitivePeerDependencies:
- '@date-fns/tz'
- '@emotion/is-prop-valid'
@@ -3688,6 +3838,65 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3))':
dependencies:
'@cfworker/json-schema': 4.1.1
'@standard-schema/spec': 1.1.0
js-tiktoken: 1.0.21
langsmith: 0.7.15(openai@6.45.0(zod@4.4.3))
mustache: 4.2.0
p-queue: 6.6.2
zod: 4.4.3
transitivePeerDependencies:
- '@opentelemetry/api'
- '@opentelemetry/exporter-trace-otlp-proto'
- '@opentelemetry/sdk-trace-base'
- openai
- ws
'@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))':
dependencies:
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
'@langchain/langgraph-sdk@1.9.25(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
'@langchain/protocol': 0.0.18
'@types/json-schema': 7.0.15
p-queue: 9.3.1
p-retry: 7.1.1
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@langchain/langgraph@1.4.7(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)':
dependencies:
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
'@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))
'@langchain/langgraph-sdk': 1.9.25(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@langchain/protocol': 0.0.18
'@standard-schema/spec': 1.1.0
zod: 4.4.3
transitivePeerDependencies:
- react
- react-dom
- svelte
- vue
'@langchain/openai@1.5.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))':
dependencies:
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
js-tiktoken: 1.0.21
openai: 6.45.0(zod@4.4.3)
zod: 4.4.3
transitivePeerDependencies:
- '@aws-sdk/credential-provider-node'
- '@smithy/hash-node'
- '@smithy/signature-v4'
- ws
'@langchain/protocol@0.0.18': {}
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -4328,6 +4537,8 @@ snapshots:
balanced-match@4.0.4: {}
base64-js@1.5.1: {}
brace-expansion@1.1.15:
dependencies:
balanced-match: 1.0.2
@@ -4929,6 +5140,8 @@ snapshots:
esutils@2.0.3: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
expect-type@1.4.0: {}
@@ -5189,6 +5402,8 @@ snapshots:
is-negative-zero@2.0.3: {}
is-network-error@1.3.2: {}
is-number-object@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -5250,6 +5465,10 @@ snapshots:
jiti@2.7.0: {}
js-tiktoken@1.0.21:
dependencies:
base64-js: 1.5.1
js-tokens@4.0.0: {}
js-yaml@4.3.0:
@@ -5277,6 +5496,30 @@ snapshots:
dependencies:
json-buffer: 3.0.1
langchain@1.5.2(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(openai@6.45.0(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
'@langchain/langgraph': 1.4.7(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)
'@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))
langsmith: 0.7.15(openai@6.45.0(zod@4.4.3))
zod: 4.4.3
transitivePeerDependencies:
- '@opentelemetry/api'
- '@opentelemetry/exporter-trace-otlp-proto'
- '@opentelemetry/sdk-trace-base'
- openai
- react
- react-dom
- svelte
- vue
- ws
langsmith@0.7.15(openai@6.45.0(zod@4.4.3)):
dependencies:
p-queue: 6.6.2
optionalDependencies:
openai: 6.45.0(zod@4.4.3)
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -5419,6 +5662,8 @@ snapshots:
ms@2.1.3: {}
mustache@4.2.0: {}
nanoid@3.3.15: {}
napi-postinstall@0.3.4: {}
@@ -5507,6 +5752,10 @@ snapshots:
regex: 6.1.0
regex-recursion: 6.0.2
openai@6.45.0(zod@4.4.3):
optionalDependencies:
zod: 4.4.3
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -5522,6 +5771,8 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-finally@1.0.0: {}
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
@@ -5530,6 +5781,26 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-queue@6.6.2:
dependencies:
eventemitter3: 4.0.7
p-timeout: 3.2.0
p-queue@9.3.1:
dependencies:
eventemitter3: 5.0.4
p-timeout: 7.0.1
p-retry@7.1.1:
dependencies:
is-network-error: 1.3.2
p-timeout@3.2.0:
dependencies:
p-finally: 1.0.0
p-timeout@7.0.1: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -6204,4 +6475,6 @@ snapshots:
yocto-queue@0.1.0: {}
zod@4.4.3: {}
zwitch@2.0.4: {}