Compare commits
33 Commits
0bc296a8ee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee502a97ed | |||
| 929beb7d84 | |||
| 4efb8ea3b6 | |||
| 2682a509dc | |||
| b8d08cd461 | |||
| ef4dd21d32 | |||
| 3aabdf0c4a | |||
| 933d06dbbb | |||
| 7ac8d253cd | |||
| 153c501cf7 | |||
| a8776f9d79 | |||
| 6a1add3422 | |||
| 9a437f4cbf | |||
| ca7b0bb869 | |||
| f74b7f3ca0 | |||
| 0d5093ac6c | |||
| ae561a7d45 | |||
| 6ed7508983 | |||
| b586756226 | |||
| c3cf1d49cc | |||
| e204974b57 | |||
| 6aa72d3b43 | |||
| 0a77e87d55 | |||
| 9fd2088a2a | |||
| fb15d4db47 | |||
| c4d5222ddd | |||
| 901e36b7a7 | |||
| 41eb3b73a9 | |||
| b89a3c762b | |||
| 4ba2a11b88 | |||
| bf3dd256ab | |||
| ee9e251f53 | |||
| 36eae0be53 |
@@ -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"],
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -251,6 +352,73 @@ return jsonSuccess("健康数据已加载", data);
|
||||
|
||||
#### Correct
|
||||
|
||||
## Scenario: Collaboration Module Data Management
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: changing 设备运维, 公告通知, 规则预警, or 家属服务 pages, APIs, seed data, or schema.
|
||||
- Applies because these modules are real Drizzle/PostgreSQL-backed collaboration workspaces, not reserved/static module pages.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- `GET /api/devices/assets`, `POST /api/devices/assets`, `PATCH|DELETE /api/devices/assets/[id]`
|
||||
- `POST /api/devices/tickets`, `PATCH|DELETE /api/devices/tickets/[id]`
|
||||
- `GET|POST /api/notices`, `POST|PATCH|DELETE /api/notices/[id]`
|
||||
- `GET|POST /api/alerts/rules`, `PATCH|DELETE /api/alerts/rules/[id]`
|
||||
- `POST /api/alerts/triggers`, `PATCH|DELETE /api/alerts/triggers/[id]`
|
||||
- `GET|POST /api/family/contacts`, `PATCH|DELETE /api/family/contacts/[id]`
|
||||
- `POST /api/family/visits`, `PATCH|DELETE /api/family/visits/[id]`
|
||||
- `POST /api/family/feedback`, `PATCH|DELETE /api/family/feedback/[id]`
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Reads require module read permission: `device:read`, `notice:read`, `alert:read`, or `family:read`.
|
||||
- Mutations require module manage permission: `device:manage`, `notice:manage`, `alert:manage`, or `family:manage`.
|
||||
- All records must be scoped by active `organizationId`; updates/deletes must filter by both `id` and `organizationId`.
|
||||
- Read APIs return `{ success: true, reason, data }` where `data` is the module workspace DTO.
|
||||
- Mutation APIs return the changed resource under a resource-specific key and write an audit log after successful persistence.
|
||||
- Seed demo rows only inside `seedDefaultWorkspaceData(organizationId)` and connect them to real seeded elders/devices/rules where applicable.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing session or permission -> response from `requirePermission`; domain helper must not be called.
|
||||
- Missing active organization -> `400` with a Chinese reason asking the user to select an organization.
|
||||
- Invalid enum, empty required title/name/content, invalid date -> `400`.
|
||||
- Cross-organization or missing referenced record -> `404` with entity-specific reason.
|
||||
- Insert/update returning no row -> structured mutation failure with status `500` or `404`.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: Server page checks read permission, loads persisted workspace data, and passes serializable DTOs to a Client Component.
|
||||
- Good: Client refreshes by calling the module read API after mutations.
|
||||
- Good: notice read receipts use `POST /api/notices/[id]` with read permission; create/update/delete use manage permission.
|
||||
- Base: alert rules and triggers are manually managed in v1; no background rule engine is implied.
|
||||
- Bad: rendering fake collaboration counters or rows on reserved pages after these modules have real tables.
|
||||
- Bad: updating a record by `id` alone without the active organization filter.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- `pnpm db:generate`, then review SQL for additive collaboration enums/tables/indexes/FKs.
|
||||
- `pnpm lint`, `pnpm type-check`, `pnpm test`, and `pnpm build`.
|
||||
- API route tests should cover permission denial, missing active organization, successful create/update audit, and missing/cross-organization mutation failures.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```ts
|
||||
await database.update(alertTriggers).set({ status }).where(eq(alertTriggers.id, id));
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```ts
|
||||
await database
|
||||
.update(alertTriggers)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(and(eq(alertTriggers.id, id), eq(alertTriggers.organizationId, organizationId)));
|
||||
```
|
||||
|
||||
```ts
|
||||
const data = await listHealthAdminData(organizationId);
|
||||
return jsonSuccess("健康数据已加载", { data });
|
||||
@@ -310,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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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,
|
||||
@@ -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."}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Design
|
||||
|
||||
## Architecture
|
||||
|
||||
Add four module slices following existing patterns:
|
||||
|
||||
- `modules/devices`, `modules/notices`, `modules/alerts`, and `modules/family`
|
||||
- each module owns `types.ts`, `server/operations.ts`, and a client workspace component
|
||||
- route handlers under `app/api/<module>` validate permissions, input, organization context, and audit mutations
|
||||
- app route pages remain Server Components and pass initial data plus `canManage` into client components
|
||||
|
||||
## Data Model
|
||||
|
||||
Add Postgres enums and tables to `modules/core/server/schema.ts`:
|
||||
|
||||
- devices: asset status, ticket status, ticket priority; device assets and maintenance tickets
|
||||
- notices: notice status; notices and notice read receipts
|
||||
- alerts: rule type/status, trigger status; alert rules and alert triggers
|
||||
- family: relation/status, visit status, feedback status/type; family contacts, visit appointments, feedback records
|
||||
|
||||
All business tables include `organizationId`, timestamps, and indexes for list/status lookups. FK references use cascade for organization-owned children and set-null where historical records should survive related record deletion.
|
||||
|
||||
## Permissions
|
||||
|
||||
Extend `Permission` and seeded permission definitions:
|
||||
|
||||
- `device:read`, `device:manage`
|
||||
- `notice:read`, `notice:manage`
|
||||
- `alert:read`, `alert:manage`
|
||||
- `family:read`, `family:manage`
|
||||
|
||||
`org_admin` and `manager` receive read/manage for all four modules. `viewer` receives read permissions. `caregiver` receives read permissions for devices, alerts, and family plus existing care/health/facility access.
|
||||
|
||||
## UI Flow
|
||||
|
||||
Each workspace should provide:
|
||||
|
||||
- metric cards for key counts
|
||||
- search and status/type filters
|
||||
- tables with stable widths and empty states
|
||||
- dialogs for create/edit/delete confirmation or status updates
|
||||
- optimistic local refresh by refetching the module API after successful mutations
|
||||
|
||||
## Compatibility
|
||||
|
||||
Generate a Drizzle migration from schema changes. Existing installations need to run migrations before using the pages. Existing reserved-page component can remain for future modules but must no longer be used by these four routes.
|
||||
|
||||
## Rollback
|
||||
|
||||
The rollback point is the generated migration plus module files. If implementation gets too large, keep schema and API for all modules but reduce UI duplication by sharing small local helper components only where it does not obscure module boundaries.
|
||||
@@ -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."}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Implementation Plan
|
||||
|
||||
## Steps
|
||||
|
||||
1. Load relevant frontend, backend, and shared Trellis specs.
|
||||
2. Add schema enums/tables and update core permission types/seed role definitions.
|
||||
3. Generate the Drizzle migration.
|
||||
4. Add module type contracts, validators, and server operations for devices, notices, alerts, and family.
|
||||
5. Add API route handlers and route tests for read/manage permission behavior and organization scoping.
|
||||
6. Add local seed records for the four modules using existing organization, elder, bed, incident, health, and account context where available.
|
||||
7. Replace reserved route pages with data-backed Server Components and client workspace components.
|
||||
8. Run `pnpm type-check`, `pnpm test`, and targeted lint/build checks as time allows.
|
||||
9. Run Trellis check/update-spec finish steps and commit task changes.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
- `pnpm db:generate`
|
||||
- `pnpm type-check`
|
||||
- `pnpm test`
|
||||
- `pnpm lint`
|
||||
|
||||
## Risk Points
|
||||
|
||||
- Schema migration generation changes tracked Drizzle metadata; inspect before finalizing.
|
||||
- Avoid touching unrelated user-modified files except where required by shared types/navigation/seed integration.
|
||||
- Keep organization scoping explicit in every list and mutation query.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Build collaboration modules
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the four reserved "协同" navigation pages with real persisted business modules for 设备运维, 公告通知, 规则预警, and 家属服务 so the local养老机构工作台 can exercise end-to-end CRUD workflows backed by Drizzle/Postgres data.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- The current navigation already exposes `/devices`, `/notices`, `/alerts`, and `/family`.
|
||||
- These routes currently render `ReservedModulePages`, and the frontend spec requires reserved modules not to fabricate operational data.
|
||||
- The stack uses Next.js App Router, React client components, project-owned Kumo UI adapters, Drizzle/Postgres, route handlers under `app/api`, and permission gates through `requirePermission`.
|
||||
- Existing seed data is organization-scoped and idempotent for already-initialized workspaces.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add real schema, migrations, server operations, API routes, UI pages, permissions, and seed data for all four collaboration modules.
|
||||
- Implement complete CRUD for each module's core records, plus the key status transitions needed for daily operations.
|
||||
- Keep all records organization-scoped and prevent cross-organization reads or mutations.
|
||||
- Add audit logs for create, update, delete, and status transition mutations.
|
||||
- Replace reserved pages for both unscoped and organization-scoped app routes.
|
||||
- Keep UI consistent with existing workspaces: server page loads initial data, client workspace handles filters, tables, dialogs, and mutations.
|
||||
- Preserve TypeScript strictness and avoid new external dependencies.
|
||||
|
||||
## Module Scope
|
||||
|
||||
- 设备运维: equipment/device assets and maintenance tickets.
|
||||
- 公告通知: notices, publish/retract lifecycle, and account read receipts.
|
||||
- 规则预警: alert rules and triggered alert records. V1 persists and manages rules/triggers but does not implement a background rule engine.
|
||||
- 家属服务: family contacts, visit appointments, and family feedback. V1 does not add family login or external messaging.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Four collaboration nav pages render real data-backed workspaces instead of reserved module placeholders.
|
||||
- [ ] Each module supports list, create, edit, delete, and relevant status transitions through API routes.
|
||||
- [ ] New data is seeded for local/demo organizations without overwriting existing workspace data.
|
||||
- [ ] New permissions are registered and assigned to system roles consistently.
|
||||
- [ ] Mutation API routes require manage permissions, read API routes require read permissions, and cross-organization records cannot be mutated.
|
||||
- [ ] `pnpm db:generate`, `pnpm type-check`, and `pnpm test` pass.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Family-member authentication, family-facing mobile/client portal, SMS/push delivery, and automatic alert-rule evaluation jobs.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "collaboration-modules",
|
||||
"name": "collaboration-modules",
|
||||
"title": "Build collaboration modules",
|
||||
"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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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."}
|
||||
31
.trellis/tasks/archive/2026-07/07-03-local-mock-data/prd.md
Normal file
31
.trellis/tasks/archive/2026-07/07-03-local-mock-data/prd.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Local mock data
|
||||
|
||||
## Goal
|
||||
|
||||
Provide richer local mock data so the养老机构工作台 can be opened locally with realistic content across the existing dashboard, beds, elders, care, health, and emergency surfaces.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- The project already has Drizzle/Postgres schema and a `seedDefaultWorkspaceData(organizationId)` helper in `modules/core/server/default-workspace-data.ts`.
|
||||
- Existing default data covers rooms, beds, elders, admissions, health profiles, vital records, chronic conditions, health anomaly reviews, and care tasks.
|
||||
- Local Postgres is configured through `compose.yaml`; Drizzle uses `DATABASE_URL`.
|
||||
- This is a lightweight task: no schema change, no product workflow redesign, and no new external dependency is required.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add a larger, more varied default dataset using existing schema tables and enum values.
|
||||
- Include enough records to make local pages visibly populated: facilities/beds, elder roster, active admissions, care tasks, health vitals/reviews, chronic conditions, and emergency incidents.
|
||||
- Keep seeding idempotent for an organization that already has workspace data; do not overwrite user-created local data.
|
||||
- Preserve existing TypeScript type safety and existing data relationships by resolving records through names/codes inside the seed transaction.
|
||||
- Avoid production-only assumptions; this data is for local/demo initialization only.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Default workspace seed data has more realistic volume and state variety across the existing modules.
|
||||
- [x] Seed data inserts without foreign-key errors into a migrated local database.
|
||||
- [x] `pnpm type-check` passes.
|
||||
- [x] No schema migration is introduced for this task.
|
||||
|
||||
## Notes
|
||||
|
||||
- Out of scope: fake auth users, generated production data, cross-organization demo scenarios, and UI redesign.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "local-mock-data",
|
||||
"name": "local-mock-data",
|
||||
"title": "Local mock data",
|
||||
"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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
@@ -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."}
|
||||
@@ -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.
|
||||
43
.trellis/tasks/archive/2026-07/07-09-ai-fee-frontend/prd.md
Normal file
43
.trellis/tasks/archive/2026-07/07-09-ai-fee-frontend/prd.md
Normal 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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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 操作边界"}
|
||||
@@ -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 安全边界与收尾流程"}
|
||||
31
.trellis/tasks/archive/2026-07/07-27-sync-git-remotes/prd.md
Normal file
31
.trellis/tasks/archive/2026-07/07-27-sync-git-remotes/prd.md
Normal 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 托管配置或创建其他分支/标签。
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 9
|
||||
- **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` | ~316 | Active |
|
||||
| `journal-1.md` | ~585 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,14 @@
|
||||
<!-- @@@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` |
|
||||
| 8 | 2026-07-03 | Operations Roadmap Complete | `41807ff`, `b6b15ac`, `a0e50a9`, `0ce8cb9`, `9d73457` | `main` |
|
||||
| 7 | 2026-07-03 | Operations Dashboard Integration | `9d73457` | `main` |
|
||||
|
||||
@@ -314,3 +314,272 @@ Refined global settings invitation content and fixed settings dialog sizing/cent
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 10: Local mock data
|
||||
|
||||
**Date**: 2026-07-03
|
||||
**Task**: Local mock data
|
||||
**Branch**: `main`
|
||||
|
||||
### Summary
|
||||
|
||||
Expanded default workspace seed data across facilities, elders, admissions, health, care, and emergency incidents; validated lint, type-check, tests, build, and local database seeding.
|
||||
|
||||
### Main Changes
|
||||
|
||||
(Add details)
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `36eae0b` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 11: Build collaboration modules
|
||||
|
||||
**Date**: 2026-07-03
|
||||
**Task**: Build collaboration modules
|
||||
**Branch**: `main`
|
||||
|
||||
### Summary
|
||||
|
||||
Implemented real data-backed collaboration workspaces for devices, notices, alerts, and family services with schema, migrations, seed data, permissions, APIs, UI routes, tests, and backend spec updates.
|
||||
|
||||
### Main Changes
|
||||
|
||||
(Add details)
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `4ba2a11` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertsModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||
import AlertsPage from "../../alerts/page";
|
||||
|
||||
export default function ScopedAlertsPage(): React.ReactElement {
|
||||
return <AlertsModulePage />;
|
||||
export default async function ScopedAlertsPage(): Promise<React.ReactElement> {
|
||||
return AlertsPage();
|
||||
}
|
||||
|
||||
5
app/(app)/app/[organizationSlug]/billing/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/billing/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import BillingPage from "../../billing/page";
|
||||
|
||||
export default async function ScopedBillingPage(): Promise<React.ReactElement> {
|
||||
return BillingPage();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DevicesModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||
import DevicesPage from "../../devices/page";
|
||||
|
||||
export default function ScopedDevicesPage(): React.ReactElement {
|
||||
return <DevicesModulePage />;
|
||||
export default async function ScopedDevicesPage(): Promise<React.ReactElement> {
|
||||
return DevicesPage();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FamilyModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||
import FamilyPage from "../../family/page";
|
||||
|
||||
export default function ScopedFamilyPage(): React.ReactElement {
|
||||
return <FamilyModulePage />;
|
||||
export default async function ScopedFamilyPage(): Promise<React.ReactElement> {
|
||||
return FamilyPage();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NoticesModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||
import NoticesPage from "../../notices/page";
|
||||
|
||||
export default function ScopedNoticesPage(): React.ReactElement {
|
||||
return <NoticesModulePage />;
|
||||
export default async function ScopedNoticesPage(): Promise<React.ReactElement> {
|
||||
return NoticesPage();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import KnowledgePage from "../../../settings/knowledge/page";
|
||||
|
||||
export default async function ScopedKnowledgePage(): Promise<React.ReactElement> {
|
||||
return KnowledgePage();
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
import { AlertsModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AlertsPage(): React.ReactElement {
|
||||
return <AlertsModulePage />;
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { hasPermission } from "@/modules/core/server/permissions";
|
||||
import { AlertsWorkspaceClient } from "@/modules/alerts/components/AlertsWorkspaceClient";
|
||||
import { listAlertCenterData } from "@/modules/alerts/server/operations";
|
||||
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||
|
||||
export default async function AlertsPage(): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "alert:read")) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const data = await listAlertCenterData(organizationId);
|
||||
return <AlertsWorkspaceClient canManage={hasPermission(context.permissions, "alert:manage")} initialData={data} />;
|
||||
}
|
||||
|
||||
20
app/(app)/app/billing/page.tsx
Normal file
20
app/(app)/app/billing/page.tsx
Normal 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()} />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { DevicesModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function DevicesPage(): React.ReactElement {
|
||||
return <DevicesModulePage />;
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { hasPermission } from "@/modules/core/server/permissions";
|
||||
import { DevicesWorkspaceClient } from "@/modules/devices/components/DevicesWorkspaceClient";
|
||||
import { listDeviceOperationsData } from "@/modules/devices/server/operations";
|
||||
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||
|
||||
export default async function DevicesPage(): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "device:read")) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const data = await listDeviceOperationsData(organizationId);
|
||||
return <DevicesWorkspaceClient canManage={hasPermission(context.permissions, "device:manage")} initialData={data} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { FamilyModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function FamilyPage(): React.ReactElement {
|
||||
return <FamilyModulePage />;
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { hasPermission } from "@/modules/core/server/permissions";
|
||||
import { FamilyWorkspaceClient } from "@/modules/family/components/FamilyWorkspaceClient";
|
||||
import { listFamilyServiceData } from "@/modules/family/server/operations";
|
||||
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||
|
||||
export default async function FamilyPage(): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "family:read")) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const data = await listFamilyServiceData(organizationId);
|
||||
return <FamilyWorkspaceClient canManage={hasPermission(context.permissions, "family:manage")} initialData={data} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { NoticesModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function NoticesPage(): React.ReactElement {
|
||||
return <NoticesModulePage />;
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { hasPermission } from "@/modules/core/server/permissions";
|
||||
import { NoticesWorkspaceClient } from "@/modules/notices/components/NoticesWorkspaceClient";
|
||||
import { listNoticeCenterData } from "@/modules/notices/server/operations";
|
||||
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||
|
||||
export default async function NoticesPage(): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "notice:read")) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const data = await listNoticeCenterData(organizationId, context.account.id);
|
||||
return <NoticesWorkspaceClient canManage={hasPermission(context.permissions, "notice:manage")} initialData={data} />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
21
app/(app)/app/settings/knowledge/page.tsx
Normal file
21
app/(app)/app/settings/knowledge/page.tsx
Normal 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} />;
|
||||
}
|
||||
343
app/api/ai/ai-routes.test.ts
Normal file
343
app/api/ai/ai-routes.test.ts
Normal 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" }));
|
||||
});
|
||||
});
|
||||
62
app/api/ai/elders/[id]/analyses/route.ts
Normal file
62
app/api/ai/elders/[id]/analyses/route.ts
Normal 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);
|
||||
}
|
||||
74
app/api/ai/knowledge/[id]/route.ts
Normal file
74
app/api/ai/knowledge/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
50
app/api/ai/knowledge/route.ts
Normal file
50
app/api/ai/knowledge/route.ts
Normal 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);
|
||||
}
|
||||
74
app/api/alerts/rules/[id]/route.ts
Normal file
74
app/api/alerts/rules/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { deleteAlertRule, isAlertMutationFailure, updateAlertRule } from "@/modules/alerts/server/operations";
|
||||
import { validateAlertRuleInput } from "@/modules/alerts/types";
|
||||
|
||||
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("alert:manage", { action: "alert.rule.update", targetType: "alert_rule", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护规则", 400);
|
||||
}
|
||||
|
||||
const input = validateAlertRuleInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const rule = await updateAlertRule({ ...input.data, id, organizationId });
|
||||
if (isAlertMutationFailure(rule)) {
|
||||
return jsonFailure(rule.reason, rule.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "alert.rule.update",
|
||||
targetType: "alert_rule",
|
||||
targetId: rule.id,
|
||||
result: "success",
|
||||
reason: `更新预警规则:${rule.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("预警规则已更新", { rule });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("alert:manage", { action: "alert.rule.delete", targetType: "alert_rule", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护规则", 400);
|
||||
}
|
||||
|
||||
const rule = await deleteAlertRule({ id, organizationId });
|
||||
if (isAlertMutationFailure(rule)) {
|
||||
return jsonFailure(rule.reason, rule.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "alert.rule.delete",
|
||||
targetType: "alert_rule",
|
||||
targetId: rule.id,
|
||||
result: "success",
|
||||
reason: `删除预警规则:${rule.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("预警规则已删除", { rule });
|
||||
}
|
||||
54
app/api/alerts/rules/route.ts
Normal file
54
app/api/alerts/rules/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { createAlertRule, isAlertMutationFailure, listAlertCenterData } from "@/modules/alerts/server/operations";
|
||||
import { validateAlertRuleInput } from "@/modules/alerts/types";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const auth = await requirePermission("alert:read", { action: "alert.rule.list", targetType: "alert_rule" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后查看规则预警", 400);
|
||||
}
|
||||
|
||||
const data = await listAlertCenterData(organizationId);
|
||||
return jsonSuccess("规则预警已加载", { data });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("alert:manage", { action: "alert.rule.create", targetType: "alert_rule" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护规则", 400);
|
||||
}
|
||||
|
||||
const input = validateAlertRuleInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const rule = await createAlertRule({ ...input.data, organizationId });
|
||||
if (isAlertMutationFailure(rule)) {
|
||||
return jsonFailure(rule.reason, rule.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "alert.rule.create",
|
||||
targetType: "alert_rule",
|
||||
targetId: rule.id,
|
||||
result: "success",
|
||||
reason: `创建预警规则:${rule.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("预警规则已创建", { rule }, 201);
|
||||
}
|
||||
74
app/api/alerts/triggers/[id]/route.ts
Normal file
74
app/api/alerts/triggers/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { deleteAlertTrigger, isAlertMutationFailure, updateAlertTrigger } from "@/modules/alerts/server/operations";
|
||||
import { validateAlertTriggerInput } from "@/modules/alerts/types";
|
||||
|
||||
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("alert:manage", { action: "alert.trigger.update", targetType: "alert_trigger", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护触发记录", 400);
|
||||
}
|
||||
|
||||
const input = validateAlertTriggerInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const trigger = await updateAlertTrigger({ ...input.data, id, organizationId, accountId: auth.context.account.id });
|
||||
if (isAlertMutationFailure(trigger)) {
|
||||
return jsonFailure(trigger.reason, trigger.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "alert.trigger.update",
|
||||
targetType: "alert_trigger",
|
||||
targetId: trigger.id,
|
||||
result: "success",
|
||||
reason: `更新预警触发记录:${trigger.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("预警触发记录已更新", { trigger });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("alert:manage", { action: "alert.trigger.delete", targetType: "alert_trigger", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护触发记录", 400);
|
||||
}
|
||||
|
||||
const trigger = await deleteAlertTrigger({ id, organizationId });
|
||||
if (isAlertMutationFailure(trigger)) {
|
||||
return jsonFailure(trigger.reason, trigger.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "alert.trigger.delete",
|
||||
targetType: "alert_trigger",
|
||||
targetId: trigger.id,
|
||||
result: "success",
|
||||
reason: `删除预警触发记录:${trigger.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("预警触发记录已删除", { trigger });
|
||||
}
|
||||
39
app/api/alerts/triggers/route.ts
Normal file
39
app/api/alerts/triggers/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { createAlertTrigger, isAlertMutationFailure } from "@/modules/alerts/server/operations";
|
||||
import { validateAlertTriggerInput } from "@/modules/alerts/types";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("alert:manage", { action: "alert.trigger.create", targetType: "alert_trigger" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护触发记录", 400);
|
||||
}
|
||||
|
||||
const input = validateAlertTriggerInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const trigger = await createAlertTrigger({ ...input.data, organizationId, accountId: auth.context.account.id });
|
||||
if (isAlertMutationFailure(trigger)) {
|
||||
return jsonFailure(trigger.reason, trigger.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "alert.trigger.create",
|
||||
targetType: "alert_trigger",
|
||||
targetId: trigger.id,
|
||||
result: "success",
|
||||
reason: `创建预警触发记录:${trigger.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("预警触发记录已创建", { trigger }, 201);
|
||||
}
|
||||
336
app/api/collaboration-routes.test.ts
Normal file
336
app/api/collaboration-routes.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
import type { AlertCenterData, AlertRule } from "@/modules/alerts/types";
|
||||
import type { DeviceAsset, DeviceOperationsData } from "@/modules/devices/types";
|
||||
import type { FamilyContact, FamilyServiceData } from "@/modules/family/types";
|
||||
import type { Notice } from "@/modules/notices/types";
|
||||
import {
|
||||
createAlertRule,
|
||||
listAlertCenterData,
|
||||
updateAlertTrigger,
|
||||
} from "@/modules/alerts/server/operations";
|
||||
import {
|
||||
createDeviceAsset,
|
||||
listDeviceOperationsData,
|
||||
updateMaintenanceTicket,
|
||||
} from "@/modules/devices/server/operations";
|
||||
import {
|
||||
createFamilyContact,
|
||||
listFamilyServiceData,
|
||||
updateFamilyVisit,
|
||||
} from "@/modules/family/server/operations";
|
||||
import {
|
||||
createNotice,
|
||||
listNoticeCenterData,
|
||||
markNoticeRead,
|
||||
} from "@/modules/notices/server/operations";
|
||||
|
||||
vi.mock("@/modules/core/server/auth", () => ({
|
||||
requirePermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/audit", () => ({
|
||||
recordAuditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/devices/server/operations", () => ({
|
||||
createDeviceAsset: vi.fn(),
|
||||
isDeviceMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
|
||||
listDeviceOperationsData: vi.fn(),
|
||||
updateMaintenanceTicket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/notices/server/operations", () => ({
|
||||
createNotice: vi.fn(),
|
||||
isNoticeMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
|
||||
listNoticeCenterData: vi.fn(),
|
||||
markNoticeRead: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/alerts/server/operations", () => ({
|
||||
createAlertRule: vi.fn(),
|
||||
isAlertMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
|
||||
listAlertCenterData: vi.fn(),
|
||||
updateAlertTrigger: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/family/server/operations", () => ({
|
||||
createFamilyContact: vi.fn(),
|
||||
isFamilyMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
|
||||
listFamilyServiceData: vi.fn(),
|
||||
updateFamilyVisit: vi.fn(),
|
||||
}));
|
||||
|
||||
const account: AuthContext["account"] = {
|
||||
id: "account-1",
|
||||
name: "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",
|
||||
};
|
||||
|
||||
function createAuthContext(overrides: Partial<AuthContext> = {}): AuthContext {
|
||||
return {
|
||||
account,
|
||||
organization: {
|
||||
id: "org-1",
|
||||
name: "Demo Org",
|
||||
slug: "demo-org",
|
||||
status: "active",
|
||||
registrationEnabled: true,
|
||||
oidcEnabled: false,
|
||||
oidcIssuerUrl: "",
|
||||
oidcClientId: "",
|
||||
oidcHasClientSecret: false,
|
||||
oidcScopes: "openid profile email",
|
||||
oidcRedirectUri: "",
|
||||
oidcAvatarClaim: "picture",
|
||||
oidcAutoProvision: false,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
},
|
||||
organizations: [],
|
||||
permissions: ["device:read", "device:manage", "notice:read", "notice:manage", "alert:read", "alert:manage", "family:read", "family:manage"],
|
||||
session: {
|
||||
id: "session-1",
|
||||
accountId: "account-1",
|
||||
activeOrganizationId: "org-1",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
expiresAt: "2026-07-09T00:00:00.000Z",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const device: DeviceAsset = {
|
||||
id: "device-1",
|
||||
organizationId: "org-1",
|
||||
name: "床头呼叫器",
|
||||
code: "DEV-1",
|
||||
category: "呼叫系统",
|
||||
location: "A102",
|
||||
status: "active",
|
||||
lastInspectedAt: undefined,
|
||||
notes: "",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const deviceData: DeviceOperationsData = {
|
||||
metrics: { activeDevices: 1, maintenanceDevices: 0, openTickets: 0, urgentTickets: 0 },
|
||||
devices: [device],
|
||||
tickets: [],
|
||||
};
|
||||
|
||||
const notice: Notice = {
|
||||
id: "notice-1",
|
||||
organizationId: "org-1",
|
||||
title: "探访安排",
|
||||
content: "周六探访安排",
|
||||
audience: "全体员工",
|
||||
status: "published",
|
||||
publishedAt: "2026-07-02T00:00:00.000Z",
|
||||
createdByAccountId: "account-1",
|
||||
createdByName: "Admin",
|
||||
updatedByAccountId: "account-1",
|
||||
updatedByName: "Admin",
|
||||
readCount: 0,
|
||||
hasRead: false,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const alertRule: AlertRule = {
|
||||
id: "rule-1",
|
||||
organizationId: "org-1",
|
||||
name: "血氧低值连续告警",
|
||||
ruleType: "health",
|
||||
severity: "critical",
|
||||
status: "enabled",
|
||||
conditionSummary: "血氧低于阈值",
|
||||
suggestion: "通知医生",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const alertData: AlertCenterData = {
|
||||
metrics: { enabledRules: 1, openTriggers: 0, criticalTriggers: 0, resolvedTriggers: 0 },
|
||||
rules: [alertRule],
|
||||
triggers: [],
|
||||
};
|
||||
|
||||
const familyContact: FamilyContact = {
|
||||
id: "contact-1",
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
elderName: "王桂兰",
|
||||
name: "张敏",
|
||||
relationship: "女儿",
|
||||
phone: "13800000001",
|
||||
status: "active",
|
||||
notes: "",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const familyData: FamilyServiceData = {
|
||||
metrics: { activeContacts: 1, pendingVisits: 0, openFeedback: 0, resolvedFeedback: 0 },
|
||||
elderOptions: [{ id: "elder-1", name: "王桂兰" }],
|
||||
contacts: [familyContact],
|
||||
visits: [],
|
||||
feedback: [],
|
||||
};
|
||||
|
||||
function allowAuth(overrides: Partial<AuthContext> = {}): void {
|
||||
vi.mocked(requirePermission).mockResolvedValue({ success: true, context: createAuthContext(overrides) });
|
||||
}
|
||||
|
||||
function jsonRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/collaboration", {
|
||||
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("collaboration API routes", () => {
|
||||
it("loads device operations for the active organization", async () => {
|
||||
vi.mocked(listDeviceOperationsData).mockResolvedValue(deviceData);
|
||||
|
||||
const { GET } = await import("./devices/assets/route");
|
||||
const response = await GET();
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.data).toEqual(deviceData);
|
||||
expect(listDeviceOperationsData).toHaveBeenCalledWith("org-1");
|
||||
});
|
||||
|
||||
it("requires active organization before loading notices", async () => {
|
||||
allowAuth({ organization: undefined });
|
||||
|
||||
const { GET } = await import("./notices/route");
|
||||
const response = await GET();
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.reason).toBe("请选择机构后查看公告");
|
||||
expect(listNoticeCenterData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a device asset and records audit", async () => {
|
||||
vi.mocked(createDeviceAsset).mockResolvedValue(device);
|
||||
|
||||
const { POST } = await import("./devices/assets/route");
|
||||
const response = await POST(jsonRequest({ name: "床头呼叫器", code: "DEV-1", status: "active" }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.success).toBe(true);
|
||||
expect(createDeviceAsset).toHaveBeenCalledWith(expect.objectContaining({ organizationId: "org-1" }));
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "device.asset.create" }));
|
||||
});
|
||||
|
||||
it("updates a maintenance ticket and propagates cross-organization failures", async () => {
|
||||
vi.mocked(updateMaintenanceTicket).mockResolvedValue({ success: false, reason: "维修工单不存在", status: 404 });
|
||||
|
||||
const { PATCH } = await import("./devices/tickets/[id]/route");
|
||||
const response = await PATCH(jsonRequest({ title: "保养", priority: "normal", status: "open" }), {
|
||||
params: Promise.resolve({ id: "ticket-from-other-org" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.reason).toBe("维修工单不存在");
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates and marks notices read", async () => {
|
||||
vi.mocked(createNotice).mockResolvedValue(notice);
|
||||
vi.mocked(markNoticeRead).mockResolvedValue({ ...notice, hasRead: true, readCount: 1 });
|
||||
|
||||
const { POST } = await import("./notices/route");
|
||||
const createResponse = await POST(jsonRequest({ title: "探访安排", content: "周六探访安排", status: "published" }));
|
||||
expect(createResponse.status).toBe(201);
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "notice.create" }));
|
||||
|
||||
vi.clearAllMocks();
|
||||
allowAuth();
|
||||
const { POST: markReadPost } = await import("./notices/[id]/route");
|
||||
const readResponse = await markReadPost(jsonRequest({}), { params: Promise.resolve({ id: "notice-1" }) });
|
||||
expect(readResponse.status).toBe(200);
|
||||
expect(markNoticeRead).toHaveBeenCalledWith({ accountId: "account-1", id: "notice-1", organizationId: "org-1" });
|
||||
});
|
||||
|
||||
it("loads and creates alert rules", async () => {
|
||||
vi.mocked(listAlertCenterData).mockResolvedValue(alertData);
|
||||
vi.mocked(createAlertRule).mockResolvedValue(alertRule);
|
||||
|
||||
const { GET, POST } = await import("./alerts/rules/route");
|
||||
const listResponse = await GET();
|
||||
expect(listResponse.status).toBe(200);
|
||||
expect(listAlertCenterData).toHaveBeenCalledWith("org-1");
|
||||
|
||||
const createResponse = await POST(jsonRequest({ name: "血氧低值连续告警", ruleType: "health", severity: "critical", status: "enabled" }));
|
||||
expect(createResponse.status).toBe(201);
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "alert.rule.create" }));
|
||||
});
|
||||
|
||||
it("updates alert trigger failures without audit", async () => {
|
||||
vi.mocked(updateAlertTrigger).mockResolvedValue({ success: false, reason: "预警触发记录不存在", status: 404 });
|
||||
|
||||
const { PATCH } = await import("./alerts/triggers/[id]/route");
|
||||
const response = await PATCH(jsonRequest({ title: "触发", status: "resolved" }), { params: Promise.resolve({ id: "trigger-other-org" }) });
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.reason).toBe("预警触发记录不存在");
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads family services and creates contacts", async () => {
|
||||
vi.mocked(listFamilyServiceData).mockResolvedValue(familyData);
|
||||
vi.mocked(createFamilyContact).mockResolvedValue(familyContact);
|
||||
|
||||
const { GET, POST } = await import("./family/contacts/route");
|
||||
const listResponse = await GET();
|
||||
expect(listResponse.status).toBe(200);
|
||||
expect(listFamilyServiceData).toHaveBeenCalledWith("org-1");
|
||||
|
||||
const createResponse = await POST(
|
||||
jsonRequest({ elderId: "elder-1", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active" }),
|
||||
);
|
||||
expect(createResponse.status).toBe(201);
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "family.contact.create" }));
|
||||
});
|
||||
|
||||
it("updates family visit failures without audit", async () => {
|
||||
vi.mocked(updateFamilyVisit).mockResolvedValue({ success: false, reason: "探访预约不存在", status: 404 });
|
||||
|
||||
const { PATCH } = await import("./family/visits/[id]/route");
|
||||
const response = await PATCH(
|
||||
jsonRequest({ elderId: "elder-1", scheduledAt: "2026-07-02T08:00:00.000Z", status: "approved" }),
|
||||
{ params: Promise.resolve({ id: "visit-other-org" }) },
|
||||
);
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.reason).toBe("探访预约不存在");
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
74
app/api/devices/assets/[id]/route.ts
Normal file
74
app/api/devices/assets/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { deleteDeviceAsset, isDeviceMutationFailure, updateDeviceAsset } from "@/modules/devices/server/operations";
|
||||
import { validateDeviceAssetInput } from "@/modules/devices/types";
|
||||
|
||||
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("device:manage", { action: "device.asset.update", targetType: "device_asset", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护设备", 400);
|
||||
}
|
||||
|
||||
const input = validateDeviceAssetInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const device = await updateDeviceAsset({ ...input.data, id, organizationId });
|
||||
if (isDeviceMutationFailure(device)) {
|
||||
return jsonFailure(device.reason, device.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "device.asset.update",
|
||||
targetType: "device_asset",
|
||||
targetId: device.id,
|
||||
result: "success",
|
||||
reason: `更新设备:${device.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("设备已更新", { device });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("device:manage", { action: "device.asset.delete", targetType: "device_asset", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护设备", 400);
|
||||
}
|
||||
|
||||
const device = await deleteDeviceAsset({ id, organizationId });
|
||||
if (isDeviceMutationFailure(device)) {
|
||||
return jsonFailure(device.reason, device.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "device.asset.delete",
|
||||
targetType: "device_asset",
|
||||
targetId: device.id,
|
||||
result: "success",
|
||||
reason: `删除设备:${device.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("设备已删除", { device });
|
||||
}
|
||||
54
app/api/devices/assets/route.ts
Normal file
54
app/api/devices/assets/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { createDeviceAsset, isDeviceMutationFailure, listDeviceOperationsData } from "@/modules/devices/server/operations";
|
||||
import { validateDeviceAssetInput } from "@/modules/devices/types";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const auth = await requirePermission("device:read", { action: "device.asset.list", targetType: "device_asset" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后查看设备运维", 400);
|
||||
}
|
||||
|
||||
const data = await listDeviceOperationsData(organizationId);
|
||||
return jsonSuccess("设备运维数据已加载", { data });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("device:manage", { action: "device.asset.create", targetType: "device_asset" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护设备", 400);
|
||||
}
|
||||
|
||||
const input = validateDeviceAssetInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const device = await createDeviceAsset({ ...input.data, organizationId });
|
||||
if (isDeviceMutationFailure(device)) {
|
||||
return jsonFailure(device.reason, device.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "device.asset.create",
|
||||
targetType: "device_asset",
|
||||
targetId: device.id,
|
||||
result: "success",
|
||||
reason: `创建设备:${device.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("设备已创建", { device }, 201);
|
||||
}
|
||||
74
app/api/devices/tickets/[id]/route.ts
Normal file
74
app/api/devices/tickets/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { deleteMaintenanceTicket, isDeviceMutationFailure, updateMaintenanceTicket } from "@/modules/devices/server/operations";
|
||||
import { validateMaintenanceTicketInput } from "@/modules/devices/types";
|
||||
|
||||
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("device:manage", { action: "device.ticket.update", targetType: "maintenance_ticket", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护工单", 400);
|
||||
}
|
||||
|
||||
const input = validateMaintenanceTicketInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const ticket = await updateMaintenanceTicket({ ...input.data, id, organizationId });
|
||||
if (isDeviceMutationFailure(ticket)) {
|
||||
return jsonFailure(ticket.reason, ticket.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "device.ticket.update",
|
||||
targetType: "maintenance_ticket",
|
||||
targetId: ticket.id,
|
||||
result: "success",
|
||||
reason: `更新维修工单:${ticket.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("维修工单已更新", { ticket });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("device:manage", { action: "device.ticket.delete", targetType: "maintenance_ticket", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护工单", 400);
|
||||
}
|
||||
|
||||
const ticket = await deleteMaintenanceTicket({ id, organizationId });
|
||||
if (isDeviceMutationFailure(ticket)) {
|
||||
return jsonFailure(ticket.reason, ticket.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "device.ticket.delete",
|
||||
targetType: "maintenance_ticket",
|
||||
targetId: ticket.id,
|
||||
result: "success",
|
||||
reason: `删除维修工单:${ticket.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("维修工单已删除", { ticket });
|
||||
}
|
||||
39
app/api/devices/tickets/route.ts
Normal file
39
app/api/devices/tickets/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { createMaintenanceTicket, isDeviceMutationFailure } from "@/modules/devices/server/operations";
|
||||
import { validateMaintenanceTicketInput } from "@/modules/devices/types";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("device:manage", { action: "device.ticket.create", targetType: "maintenance_ticket" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护工单", 400);
|
||||
}
|
||||
|
||||
const input = validateMaintenanceTicketInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const ticket = await createMaintenanceTicket({ ...input.data, organizationId });
|
||||
if (isDeviceMutationFailure(ticket)) {
|
||||
return jsonFailure(ticket.reason, ticket.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "device.ticket.create",
|
||||
targetType: "maintenance_ticket",
|
||||
targetId: ticket.id,
|
||||
result: "success",
|
||||
reason: `创建维修工单:${ticket.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("维修工单已创建", { ticket }, 201);
|
||||
}
|
||||
74
app/api/family/contacts/[id]/route.ts
Normal file
74
app/api/family/contacts/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { deleteFamilyContact, isFamilyMutationFailure, updateFamilyContact } from "@/modules/family/server/operations";
|
||||
import { validateFamilyContactInput } from "@/modules/family/types";
|
||||
|
||||
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("family:manage", { action: "family.contact.update", targetType: "family_contact", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护家属联系人", 400);
|
||||
}
|
||||
|
||||
const input = validateFamilyContactInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const contact = await updateFamilyContact({ ...input.data, id, organizationId });
|
||||
if (isFamilyMutationFailure(contact)) {
|
||||
return jsonFailure(contact.reason, contact.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.contact.update",
|
||||
targetType: "family_contact",
|
||||
targetId: contact.id,
|
||||
result: "success",
|
||||
reason: `更新家属联系人:${contact.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("家属联系人已更新", { contact });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("family:manage", { action: "family.contact.delete", targetType: "family_contact", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护家属联系人", 400);
|
||||
}
|
||||
|
||||
const contact = await deleteFamilyContact({ id, organizationId });
|
||||
if (isFamilyMutationFailure(contact)) {
|
||||
return jsonFailure(contact.reason, contact.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.contact.delete",
|
||||
targetType: "family_contact",
|
||||
targetId: contact.id,
|
||||
result: "success",
|
||||
reason: `删除家属联系人:${contact.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("家属联系人已删除", { contact });
|
||||
}
|
||||
54
app/api/family/contacts/route.ts
Normal file
54
app/api/family/contacts/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { createFamilyContact, isFamilyMutationFailure, listFamilyServiceData } from "@/modules/family/server/operations";
|
||||
import { validateFamilyContactInput } from "@/modules/family/types";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const auth = await requirePermission("family:read", { action: "family.contact.list", targetType: "family_contact" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后查看家属服务", 400);
|
||||
}
|
||||
|
||||
const data = await listFamilyServiceData(organizationId);
|
||||
return jsonSuccess("家属服务已加载", { data });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("family:manage", { action: "family.contact.create", targetType: "family_contact" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护家属联系人", 400);
|
||||
}
|
||||
|
||||
const input = validateFamilyContactInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const contact = await createFamilyContact({ ...input.data, organizationId });
|
||||
if (isFamilyMutationFailure(contact)) {
|
||||
return jsonFailure(contact.reason, contact.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.contact.create",
|
||||
targetType: "family_contact",
|
||||
targetId: contact.id,
|
||||
result: "success",
|
||||
reason: `创建家属联系人:${contact.name}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("家属联系人已创建", { contact }, 201);
|
||||
}
|
||||
74
app/api/family/feedback/[id]/route.ts
Normal file
74
app/api/family/feedback/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { deleteFamilyFeedback, isFamilyMutationFailure, updateFamilyFeedback } from "@/modules/family/server/operations";
|
||||
import { validateFamilyFeedbackInput } from "@/modules/family/types";
|
||||
|
||||
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("family:manage", { action: "family.feedback.update", targetType: "family_feedback", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护家属反馈", 400);
|
||||
}
|
||||
|
||||
const input = validateFamilyFeedbackInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const feedback = await updateFamilyFeedback({ ...input.data, id, organizationId, accountId: auth.context.account.id });
|
||||
if (isFamilyMutationFailure(feedback)) {
|
||||
return jsonFailure(feedback.reason, feedback.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.feedback.update",
|
||||
targetType: "family_feedback",
|
||||
targetId: feedback.id,
|
||||
result: "success",
|
||||
reason: `更新家属反馈:${feedback.elderName}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("家属反馈已更新", { feedback });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("family:manage", { action: "family.feedback.delete", targetType: "family_feedback", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护家属反馈", 400);
|
||||
}
|
||||
|
||||
const feedback = await deleteFamilyFeedback({ id, organizationId });
|
||||
if (isFamilyMutationFailure(feedback)) {
|
||||
return jsonFailure(feedback.reason, feedback.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.feedback.delete",
|
||||
targetType: "family_feedback",
|
||||
targetId: feedback.id,
|
||||
result: "success",
|
||||
reason: `删除家属反馈:${feedback.elderName}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("家属反馈已删除", { feedback });
|
||||
}
|
||||
39
app/api/family/feedback/route.ts
Normal file
39
app/api/family/feedback/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { createFamilyFeedback, isFamilyMutationFailure } from "@/modules/family/server/operations";
|
||||
import { validateFamilyFeedbackInput } from "@/modules/family/types";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("family:manage", { action: "family.feedback.create", targetType: "family_feedback" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护家属反馈", 400);
|
||||
}
|
||||
|
||||
const input = validateFamilyFeedbackInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const feedback = await createFamilyFeedback({ ...input.data, organizationId, accountId: auth.context.account.id });
|
||||
if (isFamilyMutationFailure(feedback)) {
|
||||
return jsonFailure(feedback.reason, feedback.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.feedback.create",
|
||||
targetType: "family_feedback",
|
||||
targetId: feedback.id,
|
||||
result: "success",
|
||||
reason: `创建家属反馈:${feedback.elderName}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("家属反馈已创建", { feedback }, 201);
|
||||
}
|
||||
74
app/api/family/visits/[id]/route.ts
Normal file
74
app/api/family/visits/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { deleteFamilyVisit, isFamilyMutationFailure, updateFamilyVisit } from "@/modules/family/server/operations";
|
||||
import { validateFamilyVisitInput } from "@/modules/family/types";
|
||||
|
||||
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("family:manage", { action: "family.visit.update", targetType: "family_visit", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护探访预约", 400);
|
||||
}
|
||||
|
||||
const input = validateFamilyVisitInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const visit = await updateFamilyVisit({ ...input.data, id, organizationId, accountId: auth.context.account.id });
|
||||
if (isFamilyMutationFailure(visit)) {
|
||||
return jsonFailure(visit.reason, visit.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.visit.update",
|
||||
targetType: "family_visit",
|
||||
targetId: visit.id,
|
||||
result: "success",
|
||||
reason: `更新探访预约:${visit.elderName}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("探访预约已更新", { visit });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("family:manage", { action: "family.visit.delete", targetType: "family_visit", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护探访预约", 400);
|
||||
}
|
||||
|
||||
const visit = await deleteFamilyVisit({ id, organizationId });
|
||||
if (isFamilyMutationFailure(visit)) {
|
||||
return jsonFailure(visit.reason, visit.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.visit.delete",
|
||||
targetType: "family_visit",
|
||||
targetId: visit.id,
|
||||
result: "success",
|
||||
reason: `删除探访预约:${visit.elderName}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("探访预约已删除", { visit });
|
||||
}
|
||||
39
app/api/family/visits/route.ts
Normal file
39
app/api/family/visits/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { createFamilyVisit, isFamilyMutationFailure } from "@/modules/family/server/operations";
|
||||
import { validateFamilyVisitInput } from "@/modules/family/types";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("family:manage", { action: "family.visit.create", targetType: "family_visit" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护探访预约", 400);
|
||||
}
|
||||
|
||||
const input = validateFamilyVisitInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const visit = await createFamilyVisit({ ...input.data, organizationId, accountId: auth.context.account.id });
|
||||
if (isFamilyMutationFailure(visit)) {
|
||||
return jsonFailure(visit.reason, visit.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "family.visit.create",
|
||||
targetType: "family_visit",
|
||||
targetId: visit.id,
|
||||
result: "success",
|
||||
reason: `创建探访预约:${visit.elderName}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("探访预约已创建", { visit }, 201);
|
||||
}
|
||||
94
app/api/notices/[id]/route.ts
Normal file
94
app/api/notices/[id]/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { deleteNotice, isNoticeMutationFailure, markNoticeRead, updateNotice } from "@/modules/notices/server/operations";
|
||||
import { validateNoticeInput } from "@/modules/notices/types";
|
||||
|
||||
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("notice:manage", { action: "notice.update", targetType: "notice", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护公告", 400);
|
||||
}
|
||||
|
||||
const input = validateNoticeInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const notice = await updateNotice({ ...input.data, id, organizationId, accountId: auth.context.account.id });
|
||||
if (isNoticeMutationFailure(notice)) {
|
||||
return jsonFailure(notice.reason, notice.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "notice.update",
|
||||
targetType: "notice",
|
||||
targetId: notice.id,
|
||||
result: "success",
|
||||
reason: `更新公告:${notice.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("公告已更新", { notice });
|
||||
}
|
||||
|
||||
export async function POST(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("notice:read", { action: "notice.read", targetType: "notice", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后查看公告", 400);
|
||||
}
|
||||
|
||||
const notice = await markNoticeRead({ id, organizationId, accountId: auth.context.account.id });
|
||||
if (isNoticeMutationFailure(notice)) {
|
||||
return jsonFailure(notice.reason, notice.status);
|
||||
}
|
||||
|
||||
return jsonSuccess("公告已标记已读", { notice });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("notice:manage", { action: "notice.delete", targetType: "notice", targetId: id });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护公告", 400);
|
||||
}
|
||||
|
||||
const notice = await deleteNotice({ id, organizationId });
|
||||
if (isNoticeMutationFailure(notice)) {
|
||||
return jsonFailure(notice.reason, notice.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "notice.delete",
|
||||
targetType: "notice",
|
||||
targetId: notice.id,
|
||||
result: "success",
|
||||
reason: `删除公告:${notice.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("公告已删除", { notice });
|
||||
}
|
||||
54
app/api/notices/route.ts
Normal file
54
app/api/notices/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import { createNotice, isNoticeMutationFailure, listNoticeCenterData } from "@/modules/notices/server/operations";
|
||||
import { validateNoticeInput } from "@/modules/notices/types";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const auth = await requirePermission("notice:read", { action: "notice.list", targetType: "notice" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后查看公告", 400);
|
||||
}
|
||||
|
||||
const data = await listNoticeCenterData(organizationId, auth.context.account.id);
|
||||
return jsonSuccess("公告通知已加载", { data });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("notice:manage", { action: "notice.create", targetType: "notice" });
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const organizationId = auth.context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return jsonFailure("请选择机构后维护公告", 400);
|
||||
}
|
||||
|
||||
const input = validateNoticeInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const notice = await createNotice({ ...input.data, organizationId, accountId: auth.context.account.id });
|
||||
if (isNoticeMutationFailure(notice)) {
|
||||
return jsonFailure(notice.reason, notice.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId,
|
||||
action: "notice.create",
|
||||
targetType: "notice",
|
||||
targetId: notice.id,
|
||||
result: "success",
|
||||
reason: `创建公告:${notice.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("公告已创建", { notice }, 201);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] {
|
||||
@@ -437,6 +468,8 @@ a,
|
||||
inset: 0 !important;
|
||||
height: fit-content;
|
||||
margin: auto !important;
|
||||
width: min(calc(100vw - 1.5rem), var(--dialog-width, 36rem)) !important;
|
||||
max-width: calc(100vw - 1.5rem) !important;
|
||||
min-width: 0 !important;
|
||||
transform: none !important;
|
||||
translate: 0 0 !important;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,14 @@ import { Dialog as KumoDialog } from "@cloudflare/kumo/components/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const dialogWidthValues = {
|
||||
sm: "28rem",
|
||||
md: "36rem",
|
||||
lg: "42rem",
|
||||
xl: "48rem",
|
||||
wide: "64rem",
|
||||
} as const;
|
||||
|
||||
type DialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
@@ -15,11 +23,16 @@ type DialogProps = {
|
||||
footer?: React.ReactNode;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
width?: keyof typeof dialogWidthValues;
|
||||
};
|
||||
|
||||
const kumoBackdropClassNames =
|
||||
"fixed inset-0 bg-kumo-recessed opacity-80 transition-all duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0";
|
||||
|
||||
type DialogStyle = React.CSSProperties & {
|
||||
"--dialog-width": string;
|
||||
};
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
title,
|
||||
@@ -28,7 +41,14 @@ export function Dialog({
|
||||
footer,
|
||||
onClose,
|
||||
className,
|
||||
width = "md",
|
||||
}: DialogProps): React.ReactElement {
|
||||
const dialogStyle: DialogStyle = {
|
||||
"--dialog-width": dialogWidthValues[width],
|
||||
transform: "none",
|
||||
translate: "0 0",
|
||||
};
|
||||
|
||||
return (
|
||||
<KumoDialog.Root
|
||||
onOpenChange={(nextOpen) => {
|
||||
@@ -40,13 +60,13 @@ export function Dialog({
|
||||
>
|
||||
<KumoDialog
|
||||
className={cn(
|
||||
"teatea-dialog fixed inset-0 z-50 m-auto flex h-fit max-h-[min(calc(100svh-1.5rem),52rem)] w-[min(calc(100vw-1.5rem),42rem)] min-w-0 flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl outline-none ring-1 ring-kumo-line",
|
||||
"teatea-dialog fixed inset-0 z-50 m-auto flex h-fit max-h-[min(calc(100svh-1.5rem),52rem)] w-[min(calc(100vw-1.5rem),var(--dialog-width))] min-w-0 flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl outline-none ring-1 ring-kumo-line",
|
||||
"data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
|
||||
className,
|
||||
)}
|
||||
data-kumo-backdrop-classes={kumoBackdropClassNames}
|
||||
size="xl"
|
||||
style={{ transform: "none", translate: "0 0" }}
|
||||
style={dialogStyle}
|
||||
>
|
||||
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-kumo-line bg-kumo-base p-5">
|
||||
<div className="min-w-0">
|
||||
|
||||
170
drizzle/0005_quiet_sandman.sql
Normal file
170
drizzle/0005_quiet_sandman.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
CREATE TYPE "public"."alert_rule_status" AS ENUM('enabled', 'disabled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."alert_rule_type" AS ENUM('health', 'care', 'device', 'incident', 'admission', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."alert_trigger_status" AS ENUM('open', 'acknowledged', 'resolved', 'closed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."device_asset_status" AS ENUM('active', 'maintenance', 'disabled', 'retired');--> statement-breakpoint
|
||||
CREATE TYPE "public"."family_contact_status" AS ENUM('active', 'suspended', 'archived');--> statement-breakpoint
|
||||
CREATE TYPE "public"."family_feedback_status" AS ENUM('open', 'in_progress', 'resolved', 'closed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."family_feedback_type" AS ENUM('service', 'care', 'meal', 'environment', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."family_visit_status" AS ENUM('requested', 'approved', 'completed', 'cancelled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."maintenance_ticket_priority" AS ENUM('low', 'normal', 'high', 'urgent');--> statement-breakpoint
|
||||
CREATE TYPE "public"."maintenance_ticket_status" AS ENUM('open', 'assigned', 'resolved', 'closed', 'cancelled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."notice_status" AS ENUM('draft', 'published', 'retracted');--> statement-breakpoint
|
||||
CREATE TABLE "alert_rules" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"rule_type" "alert_rule_type" DEFAULT 'other' NOT NULL,
|
||||
"severity" "incident_severity" DEFAULT 'warning' NOT NULL,
|
||||
"status" "alert_rule_status" DEFAULT 'enabled' NOT NULL,
|
||||
"condition_summary" text DEFAULT '' NOT NULL,
|
||||
"suggestion" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "alert_triggers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"rule_id" uuid,
|
||||
"elder_id" uuid,
|
||||
"title" text NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"status" "alert_trigger_status" DEFAULT 'open' NOT NULL,
|
||||
"source" text DEFAULT '' NOT NULL,
|
||||
"handled_by_account_id" uuid,
|
||||
"handled_at" timestamp with time zone,
|
||||
"handling_notes" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "device_assets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"category" text DEFAULT '' NOT NULL,
|
||||
"location" text DEFAULT '' NOT NULL,
|
||||
"status" "device_asset_status" DEFAULT 'active' NOT NULL,
|
||||
"last_inspected_at" timestamp with time zone,
|
||||
"notes" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "family_contacts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"elder_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"relationship" text NOT NULL,
|
||||
"phone" text NOT NULL,
|
||||
"status" "family_contact_status" DEFAULT 'active' NOT NULL,
|
||||
"notes" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "family_feedback" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"elder_id" uuid NOT NULL,
|
||||
"contact_id" uuid,
|
||||
"feedback_type" "family_feedback_type" DEFAULT 'other' NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"status" "family_feedback_status" DEFAULT 'open' NOT NULL,
|
||||
"response_notes" text DEFAULT '' NOT NULL,
|
||||
"handled_by_account_id" uuid,
|
||||
"handled_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "family_visit_appointments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"elder_id" uuid NOT NULL,
|
||||
"contact_id" uuid,
|
||||
"scheduled_at" timestamp with time zone NOT NULL,
|
||||
"status" "family_visit_status" DEFAULT 'requested' NOT NULL,
|
||||
"notes" text DEFAULT '' NOT NULL,
|
||||
"handled_by_account_id" uuid,
|
||||
"handled_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "maintenance_tickets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"device_id" uuid,
|
||||
"title" text NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"priority" "maintenance_ticket_priority" DEFAULT 'normal' NOT NULL,
|
||||
"status" "maintenance_ticket_status" DEFAULT 'open' NOT NULL,
|
||||
"assignee_label" text DEFAULT '' NOT NULL,
|
||||
"resolution_notes" text DEFAULT '' NOT NULL,
|
||||
"resolved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notice_read_receipts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"notice_id" uuid NOT NULL,
|
||||
"account_id" uuid NOT NULL,
|
||||
"read_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"content" text DEFAULT '' NOT NULL,
|
||||
"audience" text DEFAULT '全体员工' NOT NULL,
|
||||
"status" "notice_status" DEFAULT 'draft' NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"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
|
||||
ALTER TABLE "alert_rules" ADD CONSTRAINT "alert_rules_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "alert_triggers" ADD CONSTRAINT "alert_triggers_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "alert_triggers" ADD CONSTRAINT "alert_triggers_rule_id_alert_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."alert_rules"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "alert_triggers" ADD CONSTRAINT "alert_triggers_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "alert_triggers" ADD CONSTRAINT "alert_triggers_handled_by_account_id_accounts_id_fk" FOREIGN KEY ("handled_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "device_assets" ADD CONSTRAINT "device_assets_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_contacts" ADD CONSTRAINT "family_contacts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_contacts" ADD CONSTRAINT "family_contacts_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_feedback" ADD CONSTRAINT "family_feedback_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_feedback" ADD CONSTRAINT "family_feedback_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_feedback" ADD CONSTRAINT "family_feedback_contact_id_family_contacts_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."family_contacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_feedback" ADD CONSTRAINT "family_feedback_handled_by_account_id_accounts_id_fk" FOREIGN KEY ("handled_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_visit_appointments" ADD CONSTRAINT "family_visit_appointments_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_visit_appointments" ADD CONSTRAINT "family_visit_appointments_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_visit_appointments" ADD CONSTRAINT "family_visit_appointments_contact_id_family_contacts_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."family_contacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "family_visit_appointments" ADD CONSTRAINT "family_visit_appointments_handled_by_account_id_accounts_id_fk" FOREIGN KEY ("handled_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "maintenance_tickets" ADD CONSTRAINT "maintenance_tickets_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "maintenance_tickets" ADD CONSTRAINT "maintenance_tickets_device_id_device_assets_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device_assets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notice_read_receipts" ADD CONSTRAINT "notice_read_receipts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notice_read_receipts" ADD CONSTRAINT "notice_read_receipts_notice_id_notices_id_fk" FOREIGN KEY ("notice_id") REFERENCES "public"."notices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notice_read_receipts" ADD CONSTRAINT "notice_read_receipts_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notices" ADD CONSTRAINT "notices_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notices" ADD CONSTRAINT "notices_created_by_account_id_accounts_id_fk" FOREIGN KEY ("created_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notices" ADD CONSTRAINT "notices_updated_by_account_id_accounts_id_fk" FOREIGN KEY ("updated_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "alert_rules_status_lookup" ON "alert_rules" USING btree ("organization_id","status","rule_type");--> statement-breakpoint
|
||||
CREATE INDEX "alert_triggers_queue_lookup" ON "alert_triggers" USING btree ("organization_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "alert_triggers_rule_lookup" ON "alert_triggers" USING btree ("organization_id","rule_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "device_assets_code_organization_unique" ON "device_assets" USING btree ("organization_id","code");--> statement-breakpoint
|
||||
CREATE INDEX "device_assets_status_lookup" ON "device_assets" USING btree ("organization_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "family_contacts_elder_lookup" ON "family_contacts" USING btree ("organization_id","elder_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "family_feedback_queue_lookup" ON "family_feedback" USING btree ("organization_id","status","feedback_type");--> statement-breakpoint
|
||||
CREATE INDEX "family_visit_appointments_schedule_lookup" ON "family_visit_appointments" USING btree ("organization_id","status","scheduled_at");--> statement-breakpoint
|
||||
CREATE INDEX "maintenance_tickets_queue_lookup" ON "maintenance_tickets" USING btree ("organization_id","status","priority");--> statement-breakpoint
|
||||
CREATE INDEX "maintenance_tickets_device_lookup" ON "maintenance_tickets" USING btree ("organization_id","device_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "notice_read_receipts_account_notice_unique" ON "notice_read_receipts" USING btree ("notice_id","account_id");--> statement-breakpoint
|
||||
CREATE INDEX "notice_read_receipts_notice_lookup" ON "notice_read_receipts" USING btree ("organization_id","notice_id");--> statement-breakpoint
|
||||
CREATE INDEX "notices_status_lookup" ON "notices" USING btree ("organization_id","status");
|
||||
3
drizzle/0006_petite_night_nurse.sql
Normal file
3
drizzle/0006_petite_night_nurse.sql
Normal 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';
|
||||
60
drizzle/0007_purple_puff_adder.sql
Normal file
60
drizzle/0007_purple_puff_adder.sql
Normal 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");
|
||||
4496
drizzle/meta/0005_snapshot.json
Normal file
4496
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4510
drizzle/meta/0006_snapshot.json
Normal file
4510
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5034
drizzle/meta/0007_snapshot.json
Normal file
5034
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,27 @@
|
||||
"when": 1783064001398,
|
||||
"tag": "0004_silly_carnage",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
292
modules/ai/components/ElderAiAnalysisDialog.tsx
Normal file
292
modules/ai/components/ElderAiAnalysisDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user