From 41807ff557b779b9c58ce496465ff877466db227 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Fri, 3 Jul 2026 00:25:11 -0700 Subject: [PATCH] feat: add health data management workspace --- .../spec/backend/drizzle-postgres-current.md | 90 + .../check.jsonl | 1 + .../implement.jsonl | 1 + .../07-02-care-execution-workspace/prd.md | 76 + .../07-02-care-execution-workspace/task.json | 26 + .../07-02-care-service-workspace/check.jsonl | 1 + .../07-02-care-service-workspace/design.md | 191 ++ .../implement.jsonl | 1 + .../07-02-care-service-workspace/implement.md | 74 + .../tasks/07-02-care-service-workspace/prd.md | 117 + .../07-02-care-service-workspace/task.json | 26 + .../check.jsonl | 1 + .../implement.jsonl | 1 + .../prd.md | 55 + .../task.json | 26 + .../check.jsonl | 1 + .../implement.jsonl | 1 + .../07-02-emergency-incident-workspace/prd.md | 65 + .../task.json | 26 + .../check.jsonl | 1 + .../implement.jsonl | 1 + .../prd.md | 57 + .../task.json | 26 + .../check.jsonl | 1 + .../07-02-operations-module-roadmap/design.md | 47 + .../implement.jsonl | 1 + .../implement.md | 42 + .../07-02-operations-module-roadmap/prd.md | 106 + .../07-02-operations-module-roadmap/task.json | 32 + .../settings/health/page.tsx | 5 + app/(app)/app/settings/health/page.tsx | 27 + app/api/health/admin/route.ts | 22 + app/api/health/chronic-conditions/route.ts | 43 + app/api/health/health-routes.test.ts | 372 +++ app/api/health/profiles/[elderId]/route.ts | 51 + app/api/health/reviews/[id]/route.ts | 56 + app/api/health/vitals/route.ts | 43 + drizzle/0003_gray_dust.sql | 79 + drizzle/meta/0003_snapshot.json | 2752 +++++++++++++++++ drizzle/meta/_journal.json | 7 + modules/core/server/auth.ts | 2 + modules/core/server/default-workspace-data.ts | 677 ++++ modules/core/server/permissions.ts | 10 +- modules/core/server/schema.ts | 73 + modules/core/types.ts | 2 + .../health/components/HealthAdminClient.tsx | 822 +++++ modules/health/server/operations.ts | 378 +++ modules/health/types.ts | 332 ++ modules/shared/components/AppBreadcrumbs.tsx | 1 + modules/shared/lib/navigation.ts | 6 + package.json | 4 +- pnpm-lock.yaml | 497 +++ vitest.config.ts | 15 + 53 files changed, 7367 insertions(+), 3 deletions(-) create mode 100644 .trellis/tasks/07-02-care-execution-workspace/check.jsonl create mode 100644 .trellis/tasks/07-02-care-execution-workspace/implement.jsonl create mode 100644 .trellis/tasks/07-02-care-execution-workspace/prd.md create mode 100644 .trellis/tasks/07-02-care-execution-workspace/task.json create mode 100644 .trellis/tasks/07-02-care-service-workspace/check.jsonl create mode 100644 .trellis/tasks/07-02-care-service-workspace/design.md create mode 100644 .trellis/tasks/07-02-care-service-workspace/implement.jsonl create mode 100644 .trellis/tasks/07-02-care-service-workspace/implement.md create mode 100644 .trellis/tasks/07-02-care-service-workspace/prd.md create mode 100644 .trellis/tasks/07-02-care-service-workspace/task.json create mode 100644 .trellis/tasks/07-02-elder-bed-context-optimization/check.jsonl create mode 100644 .trellis/tasks/07-02-elder-bed-context-optimization/implement.jsonl create mode 100644 .trellis/tasks/07-02-elder-bed-context-optimization/prd.md create mode 100644 .trellis/tasks/07-02-elder-bed-context-optimization/task.json create mode 100644 .trellis/tasks/07-02-emergency-incident-workspace/check.jsonl create mode 100644 .trellis/tasks/07-02-emergency-incident-workspace/implement.jsonl create mode 100644 .trellis/tasks/07-02-emergency-incident-workspace/prd.md create mode 100644 .trellis/tasks/07-02-emergency-incident-workspace/task.json create mode 100644 .trellis/tasks/07-02-operations-dashboard-integration/check.jsonl create mode 100644 .trellis/tasks/07-02-operations-dashboard-integration/implement.jsonl create mode 100644 .trellis/tasks/07-02-operations-dashboard-integration/prd.md create mode 100644 .trellis/tasks/07-02-operations-dashboard-integration/task.json create mode 100644 .trellis/tasks/07-02-operations-module-roadmap/check.jsonl create mode 100644 .trellis/tasks/07-02-operations-module-roadmap/design.md create mode 100644 .trellis/tasks/07-02-operations-module-roadmap/implement.jsonl create mode 100644 .trellis/tasks/07-02-operations-module-roadmap/implement.md create mode 100644 .trellis/tasks/07-02-operations-module-roadmap/prd.md create mode 100644 .trellis/tasks/07-02-operations-module-roadmap/task.json create mode 100644 app/(app)/app/[organizationSlug]/settings/health/page.tsx create mode 100644 app/(app)/app/settings/health/page.tsx create mode 100644 app/api/health/admin/route.ts create mode 100644 app/api/health/chronic-conditions/route.ts create mode 100644 app/api/health/health-routes.test.ts create mode 100644 app/api/health/profiles/[elderId]/route.ts create mode 100644 app/api/health/reviews/[id]/route.ts create mode 100644 app/api/health/vitals/route.ts create mode 100644 drizzle/0003_gray_dust.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 modules/core/server/default-workspace-data.ts create mode 100644 modules/health/components/HealthAdminClient.tsx create mode 100644 modules/health/server/operations.ts create mode 100644 modules/health/types.ts create mode 100644 vitest.config.ts diff --git a/.trellis/spec/backend/drizzle-postgres-current.md b/.trellis/spec/backend/drizzle-postgres-current.md index d93eea9..6bb5259 100644 --- a/.trellis/spec/backend/drizzle-postgres-current.md +++ b/.trellis/spec/backend/drizzle-postgres-current.md @@ -180,3 +180,93 @@ if (!floor) { } await database.insert(rooms).values({ organizationId, floorId: floor.id, name, code }); ``` + +## Scenario: Health Admin Data Management + +### 1. Scope / Trigger + +- Trigger: adding or changing health profile, vital record, chronic condition, or health anomaly review behavior. +- Applies because health data is persisted in Drizzle tables and rendered through a settings management page, not fabricated in the operational `/app/health` placeholder. + +### 2. Signatures + +- `GET /api/health/admin` +- `PUT /api/health/profiles/[elderId]` +- `POST /api/health/vitals` +- `POST /api/health/chronic-conditions` +- `PATCH /api/health/reviews/[id]` +- `listHealthAdminData(organizationId: string): Promise` +- Mutations return either the domain DTO or `{ success: false; reason: string; status: number }`. + +### 3. Contracts + +- Health APIs must call `requirePermission` before reading or mutating: + - reads use `health:read` + - mutations use `health:manage` +- All health rows are scoped by `organizationId`. +- Elder-owned health mutations must validate the elder exists in the active organization. +- `GET /api/health/admin` returns `{ success: true; reason: string; data: HealthAdminData }`; client refresh code must read `result.data`. +- Mutation success responses follow the standard shape: + - profile: `{ success: true; reason: string; profile }` + - vital: `{ success: true; reason: string; vital; review? }` + - chronic condition: `{ success: true; reason: string; condition }` + - review: `{ success: true; reason: string; review }` +- Mutations must write audit logs after successful persistence. + +### 4. Validation & Error Matrix + +- Missing active organization -> `400` with a Chinese reason instructing the user to select an organization. +- Missing `elderId` for vital/condition -> `400`. +- Invalid date/source/status/numeric field -> `400`. +- Elder not found or belongs to another organization -> `404` / `老人档案不存在`. +- Review not found or belongs to another organization -> `404` / `异常复核记录不存在`. +- Missing permission -> `403` from `requirePermission`; route must not call domain helpers. +- Insert/update returning no row -> `500` mutation failure. + +### 5. Good/Base/Bad Cases + +- Good: keep `/app/settings/health` as the backend management surface and leave `/app/health` operational until real operational data exists. +- Good: return the admin payload under a `data` key so route output and client refresh types match. +- Good: seed demo health rows only inside the default workspace seeding transaction and tie them to real seeded elders. +- Base: anomaly review rows may be created automatically from MVP vital thresholds. +- Bad: render UI-only health records or counters on static module pages. +- Bad: hide mutation buttons in the UI without enforcing `health:manage` in the Route Handler. +- Bad: update reviews by `id` alone without also filtering by active `organizationId`. + +### 6. Tests Required + +- `pnpm test` with API assertions for each health route. +- Route tests must cover at least: happy path, permission denial, missing active organization, invalid input, and missing/cross-organization IDs. +- `pnpm db:generate` after schema changes, then review the generated SQL for only additive health enum/table/index/FK changes. +- `pnpm lint`, `pnpm type-check`, and `pnpm build`. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const data = await listHealthAdminData(organizationId); +return jsonSuccess("健康数据已加载", data); +``` + +#### Correct + +```ts +const data = await listHealthAdminData(organizationId); +return jsonSuccess("健康数据已加载", { data }); +``` + +#### Wrong + +```ts +await database.update(healthAnomalyReviews).set({ status }).where(eq(healthAnomalyReviews.id, id)); +``` + +#### Correct + +```ts +await database + .update(healthAnomalyReviews) + .set({ status, updatedAt: new Date() }) + .where(and(eq(healthAnomalyReviews.id, id), eq(healthAnomalyReviews.organizationId, organizationId))); +``` diff --git a/.trellis/tasks/07-02-care-execution-workspace/check.jsonl b/.trellis/tasks/07-02-care-execution-workspace/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-care-execution-workspace/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-care-execution-workspace/implement.jsonl b/.trellis/tasks/07-02-care-execution-workspace/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-care-execution-workspace/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-care-execution-workspace/prd.md b/.trellis/tasks/07-02-care-execution-workspace/prd.md new file mode 100644 index 0000000..46721dc --- /dev/null +++ b/.trellis/tasks/07-02-care-execution-workspace/prd.md @@ -0,0 +1,76 @@ +# 护理服务执行工作台 + +## Goal + +Replace the current `护理服务` placeholder with a real daily care execution workspace. The MVP unit is a persisted care task / service execution record, not a care-plan generation engine. + +## Confirmed Facts + +- `/app/care` currently renders `ModulePage` only. +- `/app/{organizationSlug}/care` delegates to the unscoped care page. +- Existing elder, bed, and admission data are persisted in Drizzle. +- Navigation already exposes `护理服务` under the operation group. +- Historical product direction includes care tasks, dispatch,巡检打卡, service records, and service evaluation. + +## Requirements + +- Add Drizzle-backed care execution records scoped by organization. +- Care records should link to a real elder where applicable. +- Care records should support at least: + - title/name + - care type + - priority + - status + - scheduled time or due time + - assignee label or executor label + - execution notes + - completed time +- Replace `/app/care` and `/app/{organizationSlug}/care` with a real workspace. +- The workspace should show summary metrics, filters, and a dense care task table/list. +- Operators must be able to filter by status, priority, care type, and elder/search terms. +- Authorized operators can mark care items as in progress and completed. +- Completion must persist notes and completion timestamp. +- Mutations must record audit logs. +- Default workspace seed data must include representative care examples covering multiple statuses and care types. + +## Permissions + +- Prefer explicit care permissions if adding new permissions is practical: + - `care:read` + - `care:manage` +- If scope needs to stay smaller, reuse existing `elder:update` temporarily only if documented in the design. +- Server-side permissions are required for all mutation APIs. + +## Acceptance Criteria + +- [ ] `/app/care` no longer renders the generic placeholder. +- [ ] `/app/{organizationSlug}/care` renders the same real care workspace. +- [ ] Care records are loaded from Drizzle and scoped to the active organization. +- [ ] Default seeded workspace includes care tasks tied to seeded elders. +- [ ] The UI shows pending, in-progress, completed, and overdue/high-priority examples. +- [ ] Authorized users can move a care item to in-progress and completed from the UI. +- [ ] Completed care items persist execution notes and completion time. +- [ ] Unauthorized mutation attempts return structured failures. +- [ ] Care mutations write audit log entries. +- [ ] `pnpm db:generate` is run if schema changes are made. +- [ ] `pnpm lint` passes. +- [ ] `pnpm type-check` passes. +- [ ] `pnpm build` passes. + +## Dependencies + +- Can start after or parallel with health management because it primarily depends on existing elder/admission data. +- Dashboard integration should wait until this task is complete. + +## Out Of Scope + +- Care plan template management. +- Automatic recurring task generation. +- Service billing or pricing. +- Family notifications. +- Mobile QR/NFC check-in. +- AI care recommendations. + +## Open Questions + +- None currently blocking planning. diff --git a/.trellis/tasks/07-02-care-execution-workspace/task.json b/.trellis/tasks/07-02-care-execution-workspace/task.json new file mode 100644 index 0000000..53fe1dd --- /dev/null +++ b/.trellis/tasks/07-02-care-execution-workspace/task.json @@ -0,0 +1,26 @@ +{ + "id": "care-execution-workspace", + "name": "care-execution-workspace", + "title": "护理服务执行工作台", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "TalexDreamSoul", + "assignee": "TalexDreamSoul", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "07-02-operations-module-roadmap", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/07-02-care-service-workspace/check.jsonl b/.trellis/tasks/07-02-care-service-workspace/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-care-service-workspace/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-care-service-workspace/design.md b/.trellis/tasks/07-02-care-service-workspace/design.md new file mode 100644 index 0000000..4c87b36 --- /dev/null +++ b/.trellis/tasks/07-02-care-service-workspace/design.md @@ -0,0 +1,191 @@ +# Health Care Admin Page Design + +## Summary + +Add a dedicated management route under `/settings/health` for persisted elder health data. This keeps operational navigation (`/health`) separate from backend data administration, matching the user's direction to split the management surface into its own page. + +## Route Boundary + +- Unscoped route: `app/(app)/app/settings/health/page.tsx` +- Scoped route: `app/(app)/app/[organizationSlug]/settings/health/page.tsx` +- Optional redirect source: none in MVP. `/app/health` stays independent. +- Navigation: add a management item under `管理系统`. +- Breadcrumbs: add `"/settings/health": "健康数据管理"`. + +The page should follow existing settings pages: + +1. Server Component loads `getCurrentAuthContext()`. +2. Redirect unauthenticated users to `/login`. +3. Redirect users without `health:read` back to a safe workspace route. +4. Load persisted initial data through server helpers. +5. Render a focused Client Component for filtering and mutation UI. + +## Data Model + +All tables use `organizationId` for tenant scope and `createdAt` / `updatedAt` timestamps. + +### `health_profiles` + +- `id` +- `organizationId` +- `elderId` +- `allergyNotes` +- `medicalHistory` +- `medicationNotes` +- `careRestrictions` +- `emergencyNotes` +- `createdAt` +- `updatedAt` + +Constraint: unique `(organizationId, elderId)` so each elder has one admin health profile. + +### `vital_records` + +- `id` +- `organizationId` +- `elderId` +- `recordedAt` +- `source`: `manual | device | import` +- `systolicBp` +- `diastolicBp` +- `heartRate` +- `temperatureTenths` +- `spo2` +- `bloodGlucoseTenths` +- `weightTenths` +- `notes` +- `createdByAccountId` +- `createdAt` +- `updatedAt` + +Integer tenths avoid PostgreSQL numeric string handling for MVP decimal values. + +### `chronic_conditions` + +- `id` +- `organizationId` +- `elderId` +- `name` +- `status`: `active | controlled | resolved` +- `diagnosedAt` +- `treatmentNotes` +- `followUpNotes` +- `createdAt` +- `updatedAt` + +### `health_anomaly_reviews` + +- `id` +- `organizationId` +- `elderId` +- `vitalRecordId` +- `severity`: `info | warning | critical` +- `status`: `pending | reviewed | resolved` +- `title` +- `description` +- `reviewedByAccountId` +- `reviewedAt` +- `resolutionNotes` +- `createdAt` +- `updatedAt` + +`vitalRecordId` is nullable so reviews can also be created directly against an elder health concern. + +## Server Helpers + +Create `modules/health/`: + +- `modules/health/types.ts` + - shared enum values, labels, API DTO types, and validators. +- `modules/health/server/operations.ts` + - `listHealthAdminData(organizationId: string)` + - `upsertHealthProfile(...)` + - `createVitalRecord(...)` + - `createChronicCondition(...)` + - `updateHealthReview(...)` +- `modules/health/components/HealthAdminClient.tsx` + - client-side tabs, filters, dialogs/forms, optimistic-free refresh. + +Server reads should batch queries by organization and join elders once. Avoid per-elder database calls. + +## API Design + +MVP route handlers: + +- `GET /api/health/admin` + - permission: `health:read` + - returns metrics, elders, profiles, recent vitals, chronic conditions, reviews. +- `PUT /api/health/profiles/[elderId]` + - permission: `health:manage` + - validates elder belongs to active organization. + - upserts one profile. +- `POST /api/health/vitals` + - permission: `health:manage` + - validates elder belongs to active organization. + - creates vital record. + - may create a pending review if values exceed MVP thresholds. +- `POST /api/health/chronic-conditions` + - permission: `health:manage` + - validates elder belongs to active organization. + - creates chronic condition. +- `PATCH /api/health/reviews/[id]` + - permission: `health:manage` + - validates review belongs to active organization. + - updates status and review metadata. + +All responses use `ApiResult`. + +## Permission Design + +Update `modules/core/types.ts` and `modules/core/server/permissions.ts`: + +- Add `health:read` +- Add `health:manage` +- Add permission definitions in category `健康` +- Grant defaults: + - `org_admin`: read/manage + - `manager`: read/manage + - `caregiver`: read/manage unless user chooses stricter admin-only behavior + - `viewer`: read + +This avoids overloading `elder:update` for a separate health management page. + +## UI Design + +The page should look like an internal management console: + +- Header: `健康数据管理`, short description, manage badge if allowed. +- Metrics: elders with profiles, vitals today, active chronic conditions, pending reviews. +- Tabs: + - `健康档案`: elder list with profile completeness and edit dialog. + - `生命体征`: recent vitals table and add-vital dialog. + - `慢病记录`: condition table and add-condition dialog. + - `异常复核`: pending/reviewed/resolved queue with review action. +- Filters: elder search, status/severity select, source select where useful. + +Keep tables and cards dense; no landing-page composition. + +## Compatibility And Rollback + +- Existing `/app/health` remains unchanged in MVP, limiting frontend blast radius. +- New tables are additive; rollback is dropping the generated health tables/enums and removing nav/API/page files. +- Existing seed file has uncommitted work. Implementation must read and preserve user changes in `modules/core/server/default-workspace-data.ts` before adding health seed rows. + +## Validation + +Required: + +- `pnpm db:generate` +- Review generated SQL in `drizzle/` +- `pnpm lint` +- `pnpm type-check` +- `pnpm build` + +Manual checks: + +- login as authorized user and open `/app/{slug}/settings/health` +- create/update a health profile +- create a vital record +- create or review an abnormal item +- verify reload persists records +- verify mutation with insufficient permission returns `403` diff --git a/.trellis/tasks/07-02-care-service-workspace/implement.jsonl b/.trellis/tasks/07-02-care-service-workspace/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-care-service-workspace/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-care-service-workspace/implement.md b/.trellis/tasks/07-02-care-service-workspace/implement.md new file mode 100644 index 0000000..3bdfb61 --- /dev/null +++ b/.trellis/tasks/07-02-care-service-workspace/implement.md @@ -0,0 +1,74 @@ +# Implementation Plan + +## 1. Align Task Metadata + +- Update task title/description if needed so the Trellis task reflects `健康数据管理` instead of care-service workspace wording. +- Keep the existing task directory unless a rename is explicitly requested. + +## 2. Schema And Migration + +- Add health enums and tables in `modules/core/server/schema.ts`. +- Add indexes/unique constraints: + - profile unique `(organizationId, elderId)` + - recent vital lookups by `(organizationId, elderId, recordedAt)` + - review queues by `(organizationId, status, severity)` +- Run `pnpm db:generate`. +- Review generated migration SQL. + +## 3. Types And Permissions + +- Add `health:read` and `health:manage` to `modules/core/types.ts`. +- Add permission definitions and default role grants in `modules/core/server/permissions.ts`. +- Add health DTOs, labels, validators, and formatting helpers in `modules/health/types.ts`. + +## 4. Server Operations + +- Create `modules/health/server/operations.ts`. +- Implement batched list helper for the admin page. +- Implement mutation helpers for: + - profile upsert + - vital create + - chronic condition create + - review status update +- Ensure all helpers require an organization ID and validate elder/review ownership. + +## 5. API Routes + +- Add `/api/health/admin`. +- Add `/api/health/profiles/[elderId]`. +- Add `/api/health/vitals`. +- Add `/api/health/chronic-conditions`. +- Add `/api/health/reviews/[id]`. +- Use `requirePermission`, structured `jsonSuccess` / `jsonFailure`, and audit logs. + +## 6. Admin Page UI + +- Add `app/(app)/app/settings/health/page.tsx`. +- Add `app/(app)/app/[organizationSlug]/settings/health/page.tsx` delegating to the unscoped route pattern. +- Add `modules/health/components/HealthAdminClient.tsx`. +- Use existing UI adapters for buttons, cards, dialogs, inputs, selects, tables, and badges. +- Add management nav and breadcrumbs. + +## 7. Default Workspace Data + +- Carefully inspect current uncommitted `modules/core/server/default-workspace-data.ts`. +- Add health seed records only if compatible with existing default seed flow. +- Tie seed records to seeded elders and organization. + +## 8. Verification + +- Run `pnpm lint`. +- Run `pnpm type-check`. +- Run `pnpm build`. +- If a database is available, run `pnpm db:migrate` and manually test: + - scoped management page loads. + - health profile upsert persists. + - vital record create persists. + - abnormal review update persists. + - insufficient permission returns `403`. + +## Rollback Points + +- Before migration generation: schema changes can be removed directly. +- After migration generation: remove generated migration files and schema changes together. +- After UI/API addition: remove `modules/health`, health API routes, settings health pages, nav/breadcrumb additions, and health permissions. diff --git a/.trellis/tasks/07-02-care-service-workspace/prd.md b/.trellis/tasks/07-02-care-service-workspace/prd.md new file mode 100644 index 0000000..f4acc53 --- /dev/null +++ b/.trellis/tasks/07-02-care-service-workspace/prd.md @@ -0,0 +1,117 @@ +# Health Care Admin Page + +## Goal + +Build a separate backend management page for health care data instead of turning the existing operational `健康照护` module into a management surface. The first usable slice should let authorized staff manage real persisted health profiles, vital signs, chronic conditions, and abnormal-review records for elders in the active organization. + +## Background And Confirmed Facts + +- The current `健康照护` operational route renders the generic placeholder only: `app/(app)/app/health/page.tsx`. +- The scoped `健康照护` route delegates to the unscoped placeholder: `app/(app)/app/[organizationSlug]/health/page.tsx`. +- The placeholder contract explicitly forbids fabricated operational records until real Drizzle-backed data exists: `.trellis/spec/frontend/components.md`. +- The sidebar already has a separate `管理系统` group for backend management pages: `modules/shared/lib/navigation.ts`. +- Existing management routes live under `/settings/...` and have scoped mirrors under `/app/[organizationSlug]/settings/...`. +- Breadcrumbs already treat `/settings/...` as the management area: `modules/shared/components/AppBreadcrumbs.tsx`. +- The current Drizzle schema includes organizations, elders, rooms, beds, admissions, audit logs, incidents, permissions, roles, and sessions, but no health profile, vital sign, chronic condition, or health review tables: `modules/core/server/schema.ts`. +- Existing persisted feature pages load data in Server Components and pass initial records into Client Components, for example `app/(app)/app/elders/page.tsx` and `app/(app)/app/beds/page.tsx`. +- The user explicitly clarified that this should be a separate backend management page, split from the current operational health page. + +## Requirements + +### Route And Navigation + +- Add a new backend management route under the management area. +- Recommended route: `/app/settings/health` with scoped mirror `/app/{organizationSlug}/settings/health`. +- Add a `管理系统` navigation item labeled `健康数据管理`. +- Keep the existing `/app/health` operational page separate from this management page for this task. +- Breadcrumbs must show the new route under `工作台 > 管理系统 > 健康数据管理`. + +### Data Model And Persistence + +- Add Drizzle-backed tables for the health-care admin MVP. +- Health data must be scoped by `organizationId`. +- Health data that belongs to an elder must reference a real elder in the same organization. +- Minimum persisted domains: + - health profile: allergy notes, medical history, medication notes, restrictions, emergency health note. + - vital sign record: recorded time, source, blood pressure, heart rate, temperature, SpO2, blood glucose, weight, notes. + - chronic condition: condition name, status, diagnosed date or note, treatment/follow-up notes. + - abnormal review: severity, status, source vital record or elder, reviewer, reviewed time, handling notes. +- Generated migrations must stay coherent with `modules/core/server/schema.ts`. +- New mutations must use Drizzle queries or transactions through server boundaries, not legacy write helpers. + +### Permissions + +- Add explicit health permissions unless implementation review finds an existing permission is a better fit: + - `health:read` + - `health:manage` +- The new management navigation item should require `health:read`. +- Mutations should require `health:manage`. +- Default role seeding should grant sensible permissions: + - platform admin: all permissions through the existing all-permissions pattern. + - organization admin and manager: read/manage. + - caregiver: read/manage for health records if the product treats caregivers as health-data operators. + - viewer: read only. + +### API And Server Boundaries + +- Add health-specific Route Handlers under `/api/health/...`. +- All health APIs must call `requirePermission`. +- All APIs must validate active organization presence before reading or mutating organization-scoped data. +- Invalid or cross-organization IDs must return structured failures. +- Mutations must record audit logs for create/update/review actions. +- API responses must follow the existing `{ success, reason, ... }` shape. + +### Admin UI + +- The new page must be dense and operational, not a hero or marketing page. +- The first screen should show summary metrics and data management controls. +- Expected tabs or sections: + - 健康档案 + - 生命体征 + - 慢病记录 + - 异常复核 +- The page should support filtering by elder, status/severity, and search terms where useful. +- Authorized users should be able to create or update health records from the UI. +- Users without manage permission may view records but must not see working mutation paths. +- Use project UI adapters from `components/ui/*`. +- Do not fabricate UI-only health records. + +### Default Workspace Data + +- If seeded workspace data is used for first-run development/demo, it must create real persisted health records tied to real seeded elders. +- Seed records should cover normal vitals, abnormal vitals, chronic conditions, and at least one pending abnormal review. + +## Acceptance Criteria + +- [ ] `/app/settings/health` renders a real backend health management page. +- [ ] `/app/{organizationSlug}/settings/health` renders the same management page in the active workspace. +- [ ] The new management page is reachable from the `管理系统` navigation group. +- [ ] Existing `/app/health` remains separate from the backend management page. +- [ ] Health admin data is loaded from Drizzle-backed tables and scoped to the active organization. +- [ ] Health profiles, vital signs, chronic conditions, and abnormal reviews have persisted list views. +- [ ] Authorized users can create or update at least the MVP health records from the UI. +- [ ] Users without `health:manage` cannot mutate health records through the API. +- [ ] Health mutations write audit log entries. +- [ ] Invalid input and cross-organization references return structured failure responses. +- [ ] Seeded examples, if added, are real database rows tied to seeded elders. +- [ ] `pnpm db:generate` is run after schema changes and generated SQL is reviewed. +- [ ] `pnpm lint` passes. +- [ ] `pnpm type-check` passes. +- [ ] `pnpm build` passes. + +## Out Of Scope + +- Replacing the existing operational `/app/health` placeholder with a full clinical dashboard. +- Device telemetry integration. +- External medical system integration. +- AI-generated health recommendations. +- Complex clinical decision support. +- Full recurring measurement schedule engine. +- Family app notifications. +- Billing, pricing, or insurance flows. +- Replacing Route Handlers with oRPC. +- Replacing the current authentication/session implementation. + +## Open Questions + +- None currently blocking planning. diff --git a/.trellis/tasks/07-02-care-service-workspace/task.json b/.trellis/tasks/07-02-care-service-workspace/task.json new file mode 100644 index 0000000..10d6d13 --- /dev/null +++ b/.trellis/tasks/07-02-care-service-workspace/task.json @@ -0,0 +1,26 @@ +{ + "id": "care-service-workspace", + "name": "care-service-workspace", + "title": "新增健康照护后台管理页", + "description": "新增独立的健康数据管理后台页面,与现有运营健康照护入口分开。", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "TalexDreamSoul", + "assignee": "TalexDreamSoul", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "07-02-operations-module-roadmap", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/07-02-elder-bed-context-optimization/check.jsonl b/.trellis/tasks/07-02-elder-bed-context-optimization/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-elder-bed-context-optimization/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-elder-bed-context-optimization/implement.jsonl b/.trellis/tasks/07-02-elder-bed-context-optimization/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-elder-bed-context-optimization/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-elder-bed-context-optimization/prd.md b/.trellis/tasks/07-02-elder-bed-context-optimization/prd.md new file mode 100644 index 0000000..e2f1699 --- /dev/null +++ b/.trellis/tasks/07-02-elder-bed-context-optimization/prd.md @@ -0,0 +1,55 @@ +# 老人床位联动优化 + +## Goal + +Improve the existing elder and bed workspaces so operators can see relevant cross-module context without leaving the workflow: current admission, bed, recent care tasks, health abnormal records, and safety incidents. + +## Confirmed Facts + +- `老人档案` already supports persisted elder CRUD. +- `床位房间` already supports persisted room, bed, admission, transfer, and discharge workflows. +- Elder rows already show current active bed labels from admissions. +- Bed rows already show current elder names where occupied. +- Health, care, and emergency modules will add additional context after their child tasks. + +## Requirements + +- Add cross-module context to elder and bed surfaces only after the relevant underlying persisted data exists. +- Elder detail/edit surfaces should expose concise recent context: + - current bed/admission state + - recent care execution records + - recent health abnormalities or latest vital summary + - open safety/emergency incidents tied to the elder when available +- Bed workspace should expose concise current occupant context: + - elder status and care level + - active/recent care tasks for the occupant + - open safety/emergency incidents for the bed/occupant when available +- The UI should remain dense and operational; avoid nested cards or large explanatory blocks. +- Cross-module data must be read-only context unless the user is in that module's own workflow. +- Server data loading should stay scoped by active organization. + +## Acceptance Criteria + +- [ ] Elder pages show current bed/admission context from persisted data. +- [ ] Elder pages show recent health/care/emergency context once those records exist. +- [ ] Bed workspace shows current occupant context without requiring manual lookup. +- [ ] Cross-module context does not fabricate records when a module has no data. +- [ ] Unauthorized users do not see context that they lack permission to read. +- [ ] Existing elder CRUD and bed/admission workflows still work. +- [ ] `pnpm lint` passes. +- [ ] `pnpm type-check` passes. +- [ ] `pnpm build` passes. + +## Dependencies + +- Should run after health data management, care execution workspace, and emergency incident workspace have shipped basic persisted data. + +## Out Of Scope + +- Moving all workflows into a single giant elder detail page. +- Editing health/care/emergency records from elder or bed context panels. +- Replacing existing elder or bed workspace architecture. + +## Open Questions + +- None currently blocking planning. diff --git a/.trellis/tasks/07-02-elder-bed-context-optimization/task.json b/.trellis/tasks/07-02-elder-bed-context-optimization/task.json new file mode 100644 index 0000000..7866d85 --- /dev/null +++ b/.trellis/tasks/07-02-elder-bed-context-optimization/task.json @@ -0,0 +1,26 @@ +{ + "id": "elder-bed-context-optimization", + "name": "elder-bed-context-optimization", + "title": "老人床位联动优化", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "TalexDreamSoul", + "assignee": "TalexDreamSoul", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "07-02-operations-module-roadmap", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/07-02-emergency-incident-workspace/check.jsonl b/.trellis/tasks/07-02-emergency-incident-workspace/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-emergency-incident-workspace/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-emergency-incident-workspace/implement.jsonl b/.trellis/tasks/07-02-emergency-incident-workspace/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-emergency-incident-workspace/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-emergency-incident-workspace/prd.md b/.trellis/tasks/07-02-emergency-incident-workspace/prd.md new file mode 100644 index 0000000..7628eef --- /dev/null +++ b/.trellis/tasks/07-02-emergency-incident-workspace/prd.md @@ -0,0 +1,65 @@ +# 安全应急事件工作台 + +## Goal + +Replace the current `安全应急` placeholder with a real emergency and safety event workspace. The MVP should let operators view, triage, update, and close persisted incidents inside the active organization. + +## Confirmed Facts + +- `/app/emergency` currently renders `ModulePage` only. +- `/app/{organizationSlug}/emergency` delegates to the unscoped emergency page. +- `systemIncidents` already exists in the Drizzle schema and supports severity, status, title, description, source, acknowledgement, resolution, and organization scoping. +- `app/api/system/incidents/[id]/route.ts` already supports incident status updates with audit logs. +- Settings status pages already use incident status actions. + +## Requirements + +- Replace `/app/emergency` and `/app/{organizationSlug}/emergency` with a real emergency workspace. +- Use existing persisted incident data as the starting model unless implementation analysis proves a separate emergency table is required. +- Show summary metrics by severity and status. +- Show an operational incident list/table with search and status/severity filters. +- Support status transitions for open, acknowledged, resolved, and closed events. +- Support creating a manual emergency/safety event if practical in the MVP. +- Link incidents to organization and optionally to elder/bed context in a future-compatible way. +- Mutations must record audit logs. +- Default workspace seed data must include representative safety/emergency events. + +## Permissions + +- Use existing `incident:read` for viewing. +- Use existing `incident:manage` for create/update/status transitions. +- Server-side permissions are required for all mutation APIs. + +## Acceptance Criteria + +- [ ] `/app/emergency` no longer renders the generic placeholder. +- [ ] `/app/{organizationSlug}/emergency` renders the same real emergency workspace. +- [ ] Emergency events are loaded from Drizzle and scoped to the active organization. +- [ ] The workspace shows severity/status metrics and a searchable event list. +- [ ] Authorized users can transition event status from the UI. +- [ ] Event status updates persist and survive reload. +- [ ] Authorized users can create a manual safety/emergency event if included in MVP implementation. +- [ ] Unauthorized mutation attempts return structured failures. +- [ ] Emergency mutations write audit log entries. +- [ ] Default seeded workspace includes safety/emergency events. +- [ ] `pnpm lint` passes. +- [ ] `pnpm type-check` passes. +- [ ] `pnpm build` passes. + +## Dependencies + +- Can start independently because `systemIncidents` already exists. +- Elder/bed context optimization may later add richer links from incidents to elders or beds. +- Dashboard integration should wait until this task is complete. + +## Out Of Scope + +- Device telemetry ingestion. +- Real-time alarm channels. +- SMS, phone, or push notification delivery. +- Dispatch routing engine. +- Post-incident regulatory reporting. + +## Open Questions + +- None currently blocking planning. diff --git a/.trellis/tasks/07-02-emergency-incident-workspace/task.json b/.trellis/tasks/07-02-emergency-incident-workspace/task.json new file mode 100644 index 0000000..51f1193 --- /dev/null +++ b/.trellis/tasks/07-02-emergency-incident-workspace/task.json @@ -0,0 +1,26 @@ +{ + "id": "emergency-incident-workspace", + "name": "emergency-incident-workspace", + "title": "安全应急事件工作台", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "TalexDreamSoul", + "assignee": "TalexDreamSoul", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "07-02-operations-module-roadmap", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/07-02-operations-dashboard-integration/check.jsonl b/.trellis/tasks/07-02-operations-dashboard-integration/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-operations-dashboard-integration/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-operations-dashboard-integration/implement.jsonl b/.trellis/tasks/07-02-operations-dashboard-integration/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-operations-dashboard-integration/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-operations-dashboard-integration/prd.md b/.trellis/tasks/07-02-operations-dashboard-integration/prd.md new file mode 100644 index 0000000..0c26b7e --- /dev/null +++ b/.trellis/tasks/07-02-operations-dashboard-integration/prd.md @@ -0,0 +1,57 @@ +# 运营看板整合优化 + +## Goal + +Upgrade the existing operation dashboard into a cross-module summary after health, care, emergency, elder, bed, and admission data are available. The dashboard should summarize and route operators to work, not become a CRUD surface. + +## Confirmed Facts + +- The dashboard already uses real persisted elders, beds, admissions, and incidents. +- The dashboard currently does not include dedicated health or care-service metrics because those data sources do not exist yet. +- Recharts is already installed and the dashboard already uses a data-backed bed occupancy chart. + +## Requirements + +- Keep the dashboard as a Server Component data-loading surface with presentational components. +- Add health metrics after health data management exists: + - recent abnormal vital signs + - pending abnormal reviews + - chronic-condition coverage or counts where useful +- Add care metrics after care execution exists: + - today's care tasks + - pending/in-progress/completed care counts + - overdue/high-priority care count +- Add emergency metrics after emergency workspace exists: + - open safety events + - critical events + - recent resolved/closed events where useful +- Keep existing elder, bed, and admission summaries working. +- Use data-backed charts only; do not add fake visualization rows. +- Link dashboard items to the appropriate operational module route where practical. + +## Acceptance Criteria + +- [ ] Dashboard still loads persisted elder, bed, admission, and incident data. +- [ ] Dashboard includes persisted health summaries after health data is available. +- [ ] Dashboard includes persisted care execution summaries after care data is available. +- [ ] Dashboard includes persisted safety/emergency summaries after emergency data is available. +- [ ] Charts and tables are data-backed. +- [ ] Dashboard links route users into the relevant module instead of duplicating full workflows. +- [ ] `pnpm lint` passes. +- [ ] `pnpm type-check` passes. +- [ ] `pnpm build` passes. + +## Dependencies + +- Should run after health data management, care execution workspace, and emergency incident workspace are implemented. +- Should run after elder/bed context optimization if dashboard links depend on new detail surfaces. + +## Out Of Scope + +- Creating or editing records directly from the dashboard. +- Adding fake summary cards before backing data exists. +- Replacing the dashboard with a marketing landing page. + +## Open Questions + +- None currently blocking planning. diff --git a/.trellis/tasks/07-02-operations-dashboard-integration/task.json b/.trellis/tasks/07-02-operations-dashboard-integration/task.json new file mode 100644 index 0000000..6df24d4 --- /dev/null +++ b/.trellis/tasks/07-02-operations-dashboard-integration/task.json @@ -0,0 +1,26 @@ +{ + "id": "operations-dashboard-integration", + "name": "operations-dashboard-integration", + "title": "运营看板整合优化", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "TalexDreamSoul", + "assignee": "TalexDreamSoul", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "07-02-operations-module-roadmap", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/07-02-operations-module-roadmap/check.jsonl b/.trellis/tasks/07-02-operations-module-roadmap/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-operations-module-roadmap/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-operations-module-roadmap/design.md b/.trellis/tasks/07-02-operations-module-roadmap/design.md new file mode 100644 index 0000000..be45c7f --- /dev/null +++ b/.trellis/tasks/07-02-operations-module-roadmap/design.md @@ -0,0 +1,47 @@ +# Operations Module Roadmap Design + +## Boundary + +This parent task is a coordination artifact. It should not directly implement schema, API, or UI changes unless a later requirement explicitly adds parent-level integration work. + +Implementation belongs to child tasks: + +- `07-02-care-service-workspace`: health data management page. +- `07-02-care-execution-workspace`: care execution workspace. +- `07-02-emergency-incident-workspace`: emergency event workspace. +- `07-02-elder-bed-context-optimization`: cross-module context on elder and bed workflows. +- `07-02-operations-dashboard-integration`: final dashboard summaries. + +## Dependency Shape + +The modules should be delivered in foundation-to-summary order: + +1. Health data management creates health records. +2. Care execution creates care task/service records. +3. Emergency workspace operationalizes incident records. +4. Elder/bed context reads the new health/care/emergency records. +5. Dashboard integration summarizes all persisted data. + +## Shared Technical Contracts + +- Drizzle schema remains the persistence source of truth. +- New tables are additive migrations. +- Organization scoping is mandatory. +- Elder-linked records validate same-organization ownership. +- API responses use `{ success, reason, ... }`. +- Route handlers call `requirePermission`. +- Mutations record audit logs. +- Default workspace data creates real rows, not UI-only fixtures. + +## UI Contract + +- Operational pages should use dense work-focused layouts. +- Backend management pages belong under `管理系统`. +- Existing `/app/health` remains separate from backend `健康数据管理`. +- `护理服务` is daily execution work, not health data management. +- `安全应急` is event triage and closure. +- `运营看板` summarizes and routes; it is not a CRUD surface. + +## Rollback + +Rollback should happen per child task. Parent rollback means unlinking or archiving the roadmap task; child migrations and source changes must be reverted in their own rollback plans. diff --git a/.trellis/tasks/07-02-operations-module-roadmap/implement.jsonl b/.trellis/tasks/07-02-operations-module-roadmap/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/07-02-operations-module-roadmap/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-02-operations-module-roadmap/implement.md b/.trellis/tasks/07-02-operations-module-roadmap/implement.md new file mode 100644 index 0000000..e84f6e3 --- /dev/null +++ b/.trellis/tasks/07-02-operations-module-roadmap/implement.md @@ -0,0 +1,42 @@ +# Implementation Plan + +## Execution Order + +1. Start and implement `07-02-care-service-workspace` (`新增健康照护后台管理页`). +2. Start and implement `07-02-care-execution-workspace` (`护理服务执行工作台`). +3. Start and implement `07-02-emergency-incident-workspace` (`安全应急事件工作台`). +4. Start and implement `07-02-elder-bed-context-optimization` (`老人床位联动优化`). +5. Start and implement `07-02-operations-dashboard-integration` (`运营看板整合优化`). +6. Review parent acceptance criteria after all children are complete. + +## Per-Child Gate + +Before starting each child: + +- Read its `prd.md`. +- For complex children, add or refresh `design.md` and `implement.md`. +- Load `trellis-before-dev` and relevant backend/frontend/shared specs. + +Before finishing each child: + +- Run `pnpm lint`. +- Run `pnpm type-check`. +- Run `pnpm build`. +- Run `pnpm db:generate` and review SQL when schema changes are made. +- Confirm default workspace seed data is real persisted data when seed examples are added. + +## Parent Completion Gate + +The parent task is complete only after: + +- Each child task is completed or explicitly moved out of scope by the user. +- The dashboard reflects completed module data. +- Existing elder, bed, admission, auth, settings, and audit workflows still pass validation. +- Final `pnpm lint`, `pnpm type-check`, and `pnpm build` pass. + +## Risk Notes + +- Schema migrations across multiple children can conflict if implemented out of order. +- Default seed file already has active edits; every child that touches it must read it before editing. +- Permission additions must keep role seeding idempotent. +- Dashboard integration should not start before the data sources exist. diff --git a/.trellis/tasks/07-02-operations-module-roadmap/prd.md b/.trellis/tasks/07-02-operations-module-roadmap/prd.md new file mode 100644 index 0000000..60e92e1 --- /dev/null +++ b/.trellis/tasks/07-02-operations-module-roadmap/prd.md @@ -0,0 +1,106 @@ +# 运营模块完善总计划 + +## Goal + +Turn the six operation-side sidebar entries into a coherent persisted operating system for the pension workspace: + +1. 运营看板 +2. 老人档案 +3. 床位房间 +4. 健康照护 +5. 护理服务 +6. 安全应急 + +The work should be delivered as separate, independently verifiable child tasks, then integrated back into the dashboard. This parent task owns scope, ordering, and cross-module acceptance criteria. Implementation should happen in child tasks. + +## Confirmed Facts + +- `运营看板` already renders real Drizzle-backed elders, beds, admissions, and incidents data. +- `老人档案` already supports real persisted elder CRUD. +- `床位房间` already has a real bed/admission workspace backed by rooms, beds, elders, and admissions. +- `健康照护`, `护理服务`, and `安全应急` currently render placeholders or are not complete operational modules. +- The current Drizzle schema has core operational tables but no dedicated health or care-service tables yet. +- `systemIncidents` already provides a useful base for safety/emergency event state flow. +- Static module pages must not fabricate operational data before a real persisted source exists. +- The user wants all of these operation modules planned and completed, not only the currently selected sidebar item. + +## Task Map + +### Child 1: 新增健康照护后台管理页 + +- Path: `.trellis/tasks/07-02-care-service-workspace` +- Note: The slug is historical, but the task title and artifacts now describe the health data management page. +- Scope: Add backend management page for health profiles, vital signs, chronic conditions, and abnormal reviews. +- Dependency: None. This can be implemented first because it creates the health data foundation. + +### Child 2: 护理服务执行工作台 + +- Path: `.trellis/tasks/07-02-care-execution-workspace` +- Scope: Add care task/service execution records, status flow, completion notes, and seeded examples. +- Dependency: Can use existing elder/admission data. Does not depend on health admin unless a care item links to abnormal health reviews later. + +### Child 3: 安全应急事件工作台 + +- Path: `.trellis/tasks/07-02-emergency-incident-workspace` +- Scope: Replace the placeholder with an emergency event workspace using and extending persisted incident data. +- Dependency: Can build on `systemIncidents` and existing incident status actions. + +### Child 4: 老人床位联动优化 + +- Path: `.trellis/tasks/07-02-elder-bed-context-optimization` +- Scope: Improve elder and bed screens so operators can see linked health, care, admission, and incident context where available. +- Dependency: Should run after health/care/emergency have at least basic persisted data. + +### Child 5: 运营看板整合优化 + +- Path: `.trellis/tasks/07-02-operations-dashboard-integration` +- Scope: Integrate health, care, emergency, elder, bed, and admission summaries into the dashboard. +- Dependency: Should run after the relevant child data surfaces exist. + +## Delivery Order + +1. Health data management page. +2. Care execution workspace. +3. Emergency event workspace. +4. Elder/bed cross-module context improvements. +5. Dashboard integration. + +This order creates data foundations first, then links records to existing operator workflows, then summarizes everything in the dashboard. + +## Cross-Module Requirements + +- All new operational records must be Drizzle/PostgreSQL backed. +- New data must be scoped by active organization. +- New data tied to elders must reference real elders in the same organization. +- Default workspace seed data must create real persisted rows, not UI-only demo objects. +- All API responses must follow `{ success, reason, ... }`. +- Permission checks must happen on the server. +- Mutations must record audit logs. +- Navigation labels should remain clear: + - `健康照护` remains the operational health entry. + - backend health management can live under `管理系统` as `健康数据管理`. + - `护理服务` remains daily service execution. + - `安全应急` remains event response and closure. +- The app must not show hero/marketing-style module introductions for operational pages. + +## Acceptance Criteria + +- [ ] Each child task has its own PRD and implementation scope. +- [ ] Health, care, and emergency modules no longer rely on fabricated UI-only data. +- [ ] Existing elder and bed workflows remain functional after new modules are added. +- [ ] Default workspace seed data covers elders, beds, health, care, and incidents once all child tasks are complete. +- [ ] Dashboard metrics include the new persisted health/care/emergency data after integration. +- [ ] `pnpm lint` passes for each implementation child. +- [ ] `pnpm type-check` passes for each implementation child. +- [ ] `pnpm build` passes before the parent task is considered complete. + +## Out Of Scope + +- Doing all child tasks in a single implementation pass. +- Replacing Route Handlers with oRPC. +- Replacing current auth/session implementation. +- Billing, family app notifications, device telemetry integrations, and AI recommendations unless created as separate future tasks. + +## Open Questions + +- None blocking the plan. Implementation should start with Child 1 unless the user explicitly changes priority. diff --git a/.trellis/tasks/07-02-operations-module-roadmap/task.json b/.trellis/tasks/07-02-operations-module-roadmap/task.json new file mode 100644 index 0000000..bfa7fe5 --- /dev/null +++ b/.trellis/tasks/07-02-operations-module-roadmap/task.json @@ -0,0 +1,32 @@ +{ + "id": "operations-module-roadmap", + "name": "operations-module-roadmap", + "title": "运营模块完善总计划", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "TalexDreamSoul", + "assignee": "TalexDreamSoul", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [ + "07-02-care-service-workspace", + "07-02-emergency-incident-workspace", + "07-02-elder-bed-context-optimization", + "07-02-care-execution-workspace", + "07-02-operations-dashboard-integration" + ], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/app/(app)/app/[organizationSlug]/settings/health/page.tsx b/app/(app)/app/[organizationSlug]/settings/health/page.tsx new file mode 100644 index 0000000..cfa1878 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/health/page.tsx @@ -0,0 +1,5 @@ +import HealthSettingsPage from "../../../settings/health/page"; + +export default async function ScopedHealthSettingsPage(): Promise { + return HealthSettingsPage(); +} diff --git a/app/(app)/app/settings/health/page.tsx b/app/(app)/app/settings/health/page.tsx new file mode 100644 index 0000000..4264136 --- /dev/null +++ b/app/(app)/app/settings/health/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from "next/navigation"; + +import { getCurrentAuthContext } from "@/modules/core/server/auth"; +import { hasPermission } from "@/modules/core/server/permissions"; +import { listHealthAdminData } from "@/modules/health/server/operations"; +import { HealthAdminClient } from "@/modules/health/components/HealthAdminClient"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; + +export default async function HealthSettingsPage(): Promise { + const context = await getCurrentAuthContext(); + if (!context) { + redirect("/login"); + } + + if (!hasPermission(context.permissions, "health:read")) { + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); + } + + const organizationId = context.organization?.id; + if (!organizationId) { + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); + } + + const data = await listHealthAdminData(organizationId); + + return ; +} diff --git a/app/api/health/admin/route.ts b/app/api/health/admin/route.ts new file mode 100644 index 0000000..ddfc6ec --- /dev/null +++ b/app/api/health/admin/route.ts @@ -0,0 +1,22 @@ +import { jsonFailure, jsonSuccess } from "@/modules/core/server/api"; +import { requirePermission } from "@/modules/core/server/auth"; +import { listHealthAdminData } from "@/modules/health/server/operations"; + +export async function GET(): Promise { + const auth = await requirePermission("health:read", { + action: "health.admin.list", + targetType: "health", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后查看健康数据", 400); + } + + const data = await listHealthAdminData(organizationId); + return jsonSuccess("健康数据已加载", { data }); +} diff --git a/app/api/health/chronic-conditions/route.ts b/app/api/health/chronic-conditions/route.ts new file mode 100644 index 0000000..fcfc487 --- /dev/null +++ b/app/api/health/chronic-conditions/route.ts @@ -0,0 +1,43 @@ +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { createChronicCondition, isMutationFailure } from "@/modules/health/server/operations"; +import { validateChronicConditionInput } from "@/modules/health/types"; + +export async function POST(request: Request): Promise { + const auth = await requirePermission("health:manage", { + action: "health.condition.create", + targetType: "chronic_condition", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后维护慢病记录", 400); + } + + const input = validateChronicConditionInput(await readJsonBody(request)); + if (!input.success) { + return jsonFailure(input.reason); + } + + const condition = await createChronicCondition({ ...input.data, organizationId }); + if (isMutationFailure(condition)) { + return jsonFailure(condition.reason, condition.status); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "health.condition.create", + targetType: "chronic_condition", + targetId: condition.id, + result: "success", + reason: `新增慢病记录:${condition.elderName} / ${condition.name}`, + }); + + return jsonSuccess("慢病记录已创建", { condition }, 201); +} diff --git a/app/api/health/health-routes.test.ts b/app/api/health/health-routes.test.ts new file mode 100644 index 0000000..73bbf0f --- /dev/null +++ b/app/api/health/health-routes.test.ts @@ -0,0 +1,372 @@ +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 { + createChronicCondition, + createVitalRecord, + listHealthAdminData, + updateHealthReview, + upsertHealthProfile, +} from "@/modules/health/server/operations"; + +vi.mock("@/modules/core/server/auth", () => ({ + requirePermission: vi.fn(), +})); + +vi.mock("@/modules/core/server/audit", () => ({ + recordAuditLog: vi.fn(), +})); + +vi.mock("@/modules/health/server/operations", () => ({ + createChronicCondition: vi.fn(), + createVitalRecord: vi.fn(), + isMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false), + listHealthAdminData: vi.fn(), + updateHealthReview: vi.fn(), + upsertHealthProfile: 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 { + 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: ["health:read", "health: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 authContext = createAuthContext(); + +const healthAdminData = { + chronicConditions: [], + elders: [], + metrics: { + eldersWithProfiles: 0, + pendingReviews: 0, + activeChronicConditions: 0, + vitalsToday: 0, + }, + profiles: [], + reviews: [], + vitals: [], +}; + +function allowAuth(): void { + vi.mocked(requirePermission).mockResolvedValue({ + success: true, + context: authContext, + }); +} + +function jsonRequest(body: Record): Request { + return new Request("http://localhost/api/health", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function readJson(response: Response): Promise> { + return (await response.json()) as Record; +} + +beforeEach(() => { + vi.clearAllMocks(); + allowAuth(); +}); + +describe("health admin API routes", () => { + it("loads health admin data for the active organization", async () => { + vi.mocked(listHealthAdminData).mockResolvedValue(healthAdminData); + + const { GET } = await import("./admin/route"); + const response = await GET(); + const body = await readJson(response); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data).toEqual(healthAdminData); + expect(listHealthAdminData).toHaveBeenCalledWith("org-1"); + }); + + it("returns permission failures before loading admin data", async () => { + const denied = Response.json({ success: false, reason: "权限不足" }, { status: 403 }); + vi.mocked(requirePermission).mockResolvedValue({ success: false, response: denied }); + + const { GET } = await import("./admin/route"); + const response = await GET(); + const body = await readJson(response); + + expect(response.status).toBe(403); + expect(body.reason).toBe("权限不足"); + expect(listHealthAdminData).not.toHaveBeenCalled(); + }); + + it("requires an active organization before loading admin data", async () => { + vi.mocked(requirePermission).mockResolvedValue({ + success: true, + context: createAuthContext({ organization: undefined }), + }); + + const { GET } = await import("./admin/route"); + const response = await GET(); + const body = await readJson(response); + + expect(response.status).toBe(400); + expect(body.reason).toBe("请选择机构后查看健康数据"); + expect(listHealthAdminData).not.toHaveBeenCalled(); + }); + + it("upserts one elder health profile", async () => { + vi.mocked(upsertHealthProfile).mockResolvedValue({ + allergyNotes: "青霉素过敏", + careRestrictions: "低盐饮食", + createdAt: "2026-07-02T00:00:00.000Z", + elderId: "elder-1", + elderName: "王桂兰", + emergencyNotes: "胸闷需立即复核", + id: "profile-1", + medicalHistory: "高血压", + medicationNotes: "晨服降压药", + organizationId: "org-1", + updatedAt: "2026-07-02T00:00:00.000Z", + }); + + const { PUT } = await import("./profiles/[elderId]/route"); + const response = await PUT(jsonRequest({ allergyNotes: "青霉素过敏" }), { + params: Promise.resolve({ elderId: "elder-1" }), + }); + const body = await readJson(response); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(upsertHealthProfile).toHaveBeenCalledWith( + expect.objectContaining({ + elderId: "elder-1", + organizationId: "org-1", + }), + ); + expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "health.profile.upsert" })); + }); + + it("rejects invalid vital input", async () => { + const { POST } = await import("./vitals/route"); + const response = await POST(jsonRequest({ elderId: "elder-1", recordedAt: "bad-date", source: "manual" })); + const body = await readJson(response); + + expect(response.status).toBe(400); + expect(body.reason).toBe("记录时间无效"); + expect(createVitalRecord).not.toHaveBeenCalled(); + expect(recordAuditLog).not.toHaveBeenCalled(); + }); + + it("creates a vital record", async () => { + vi.mocked(createVitalRecord).mockResolvedValue({ + review: { + createdAt: "2026-07-02T00:00:00.000Z", + description: "血压偏高", + elderId: "elder-1", + elderName: "王桂兰", + id: "review-1", + organizationId: "org-1", + resolutionNotes: "", + reviewedAt: undefined, + reviewedByAccountId: undefined, + reviewedByName: undefined, + severity: "warning", + status: "pending", + title: "血压异常", + updatedAt: "2026-07-02T00:00:00.000Z", + vitalRecordId: "vital-1", + }, + vital: { + bloodGlucoseTenths: 62, + createdAt: "2026-07-02T00:00:00.000Z", + createdByAccountId: "account-1", + createdByName: "Admin", + diastolicBp: 96, + elderId: "elder-1", + elderName: "王桂兰", + heartRate: 88, + id: "vital-1", + notes: "复测", + organizationId: "org-1", + recordedAt: "2026-07-02T09:00:00.000Z", + source: "manual", + spo2: 96, + systolicBp: 158, + temperatureTenths: 367, + updatedAt: "2026-07-02T00:00:00.000Z", + weightTenths: 612, + }, + }); + + const { POST } = await import("./vitals/route"); + const response = await POST( + jsonRequest({ + elderId: "elder-1", + recordedAt: "2026-07-02T09:00:00.000Z", + source: "manual", + systolicBp: 158, + diastolicBp: 96, + }), + ); + const body = await readJson(response); + + expect(response.status).toBe(201); + expect(body.success).toBe(true); + expect(createVitalRecord).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "account-1", + organizationId: "org-1", + }), + ); + expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "health.vital.create" })); + }); + + it("creates a chronic condition", async () => { + vi.mocked(createChronicCondition).mockResolvedValue({ + createdAt: "2026-07-02T00:00:00.000Z", + diagnosedAt: "2024-01-01T00:00:00.000Z", + elderId: "elder-1", + elderName: "王桂兰", + followUpNotes: "每周复查", + id: "condition-1", + name: "高血压", + organizationId: "org-1", + status: "active", + treatmentNotes: "规律用药", + updatedAt: "2026-07-02T00:00:00.000Z", + }); + + const { POST } = await import("./chronic-conditions/route"); + const response = await POST( + jsonRequest({ + diagnosedAt: "2024-01-01T00:00:00.000Z", + elderId: "elder-1", + name: "高血压", + status: "active", + }), + ); + const body = await readJson(response); + + expect(response.status).toBe(201); + expect(body.success).toBe(true); + expect(createChronicCondition).toHaveBeenCalledWith(expect.objectContaining({ organizationId: "org-1" })); + expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "health.condition.create" })); + }); + + it("returns structured failures for missing or cross-organization chronic condition elders", async () => { + vi.mocked(createChronicCondition).mockResolvedValue({ + success: false, + reason: "老人档案不存在", + status: 404, + }); + + const { POST } = await import("./chronic-conditions/route"); + const response = await POST( + jsonRequest({ + elderId: "elder-from-other-org", + name: "高血压", + status: "active", + }), + ); + const body = await readJson(response); + + expect(response.status).toBe(404); + expect(body.reason).toBe("老人档案不存在"); + expect(recordAuditLog).not.toHaveBeenCalled(); + }); + + it("updates an anomaly review", async () => { + vi.mocked(updateHealthReview).mockResolvedValue({ + createdAt: "2026-07-02T00:00:00.000Z", + description: "血压偏高", + elderId: "elder-1", + elderName: "王桂兰", + id: "review-1", + organizationId: "org-1", + resolutionNotes: "已复测并通知医生", + reviewedAt: "2026-07-02T10:00:00.000Z", + reviewedByAccountId: "account-1", + reviewedByName: "Admin", + severity: "warning", + status: "reviewed", + title: "血压异常", + updatedAt: "2026-07-02T10:00:00.000Z", + vitalRecordId: "vital-1", + }); + + const { PATCH } = await import("./reviews/[id]/route"); + const response = await PATCH(jsonRequest({ status: "reviewed", resolutionNotes: "已复测并通知医生" }), { + params: Promise.resolve({ id: "review-1" }), + }); + const body = await readJson(response); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(updateHealthReview).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "account-1", + id: "review-1", + organizationId: "org-1", + }), + ); + expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "health.review.update" })); + }); + + it("returns structured failures for missing or cross-organization reviews", async () => { + vi.mocked(updateHealthReview).mockResolvedValue({ + success: false, + reason: "异常复核记录不存在", + status: 404, + }); + + const { PATCH } = await import("./reviews/[id]/route"); + const response = await PATCH(jsonRequest({ status: "reviewed", resolutionNotes: "已复核" }), { + params: Promise.resolve({ id: "review-from-other-org" }), + }); + const body = await readJson(response); + + expect(response.status).toBe(404); + expect(body.reason).toBe("异常复核记录不存在"); + expect(recordAuditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/health/profiles/[elderId]/route.ts b/app/api/health/profiles/[elderId]/route.ts new file mode 100644 index 0000000..285b1a2 --- /dev/null +++ b/app/api/health/profiles/[elderId]/route.ts @@ -0,0 +1,51 @@ +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { isMutationFailure, upsertHealthProfile } from "@/modules/health/server/operations"; +import { validateHealthProfileInput } from "@/modules/health/types"; + +type RouteContext = { + params: Promise<{ + elderId: string; + }>; +}; + +export async function PUT(request: Request, context: RouteContext): Promise { + const { elderId } = await context.params; + const auth = await requirePermission("health:manage", { + action: "health.profile.upsert", + targetType: "elder", + targetId: elderId, + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后维护健康档案", 400); + } + + const input = validateHealthProfileInput(await readJsonBody(request)); + if (!input.success) { + return jsonFailure(input.reason); + } + + const profile = await upsertHealthProfile({ ...input.data, elderId, organizationId }); + if (isMutationFailure(profile)) { + return jsonFailure(profile.reason, profile.status); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "health.profile.upsert", + targetType: "elder", + targetId: elderId, + result: "success", + reason: `维护健康档案:${profile.elderName}`, + }); + + return jsonSuccess("健康档案已保存", { profile }); +} diff --git a/app/api/health/reviews/[id]/route.ts b/app/api/health/reviews/[id]/route.ts new file mode 100644 index 0000000..4c4bfe3 --- /dev/null +++ b/app/api/health/reviews/[id]/route.ts @@ -0,0 +1,56 @@ +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { isMutationFailure, updateHealthReview } from "@/modules/health/server/operations"; +import { validateHealthReviewUpdateInput } from "@/modules/health/types"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("health:manage", { + action: "health.review.update", + targetType: "health_review", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后处理异常复核", 400); + } + + const input = validateHealthReviewUpdateInput(await readJsonBody(request)); + if (!input.success) { + return jsonFailure(input.reason); + } + + const review = await updateHealthReview({ + ...input.data, + id, + organizationId, + accountId: auth.context.account.id, + }); + if (isMutationFailure(review)) { + return jsonFailure(review.reason, review.status); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "health.review.update", + targetType: "health_review", + targetId: review.id, + result: "success", + reason: `处理健康异常复核:${review.elderName}`, + }); + + return jsonSuccess("异常复核已更新", { review }); +} diff --git a/app/api/health/vitals/route.ts b/app/api/health/vitals/route.ts new file mode 100644 index 0000000..c1dec22 --- /dev/null +++ b/app/api/health/vitals/route.ts @@ -0,0 +1,43 @@ +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { createVitalRecord, isMutationFailure } from "@/modules/health/server/operations"; +import { validateVitalRecordInput } from "@/modules/health/types"; + +export async function POST(request: Request): Promise { + const auth = await requirePermission("health:manage", { + action: "health.vital.create", + targetType: "vital_record", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后录入生命体征", 400); + } + + const input = validateVitalRecordInput(await readJsonBody(request)); + if (!input.success) { + return jsonFailure(input.reason); + } + + const created = await createVitalRecord({ ...input.data, organizationId, accountId: auth.context.account.id }); + if (isMutationFailure(created)) { + return jsonFailure(created.reason, created.status); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "health.vital.create", + targetType: "vital_record", + targetId: created.vital.id, + result: "success", + reason: `录入生命体征:${created.vital.elderName}`, + }); + + return jsonSuccess("生命体征已录入", created, 201); +} diff --git a/drizzle/0003_gray_dust.sql b/drizzle/0003_gray_dust.sql new file mode 100644 index 0000000..75a52ef --- /dev/null +++ b/drizzle/0003_gray_dust.sql @@ -0,0 +1,79 @@ +CREATE TYPE "public"."chronic_condition_status" AS ENUM('active', 'controlled', 'resolved');--> statement-breakpoint +CREATE TYPE "public"."health_review_status" AS ENUM('pending', 'reviewed', 'resolved');--> statement-breakpoint +CREATE TYPE "public"."vital_record_source" AS ENUM('manual', 'device', 'import');--> statement-breakpoint +CREATE TABLE "chronic_conditions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "elder_id" uuid NOT NULL, + "name" text NOT NULL, + "status" "chronic_condition_status" DEFAULT 'active' NOT NULL, + "diagnosed_at" timestamp with time zone, + "treatment_notes" text DEFAULT '' NOT NULL, + "follow_up_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 "health_anomaly_reviews" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "elder_id" uuid NOT NULL, + "vital_record_id" uuid, + "severity" "incident_severity" NOT NULL, + "status" "health_review_status" DEFAULT 'pending' NOT NULL, + "title" text NOT NULL, + "description" text NOT NULL, + "reviewed_by_account_id" uuid, + "reviewed_at" timestamp with time zone, + "resolution_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 "health_profiles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "elder_id" uuid NOT NULL, + "allergy_notes" text DEFAULT '' NOT NULL, + "medical_history" text DEFAULT '' NOT NULL, + "medication_notes" text DEFAULT '' NOT NULL, + "care_restrictions" text DEFAULT '' NOT NULL, + "emergency_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 "vital_records" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "elder_id" uuid NOT NULL, + "recorded_at" timestamp with time zone NOT NULL, + "source" "vital_record_source" DEFAULT 'manual' NOT NULL, + "systolic_bp" integer, + "diastolic_bp" integer, + "heart_rate" integer, + "temperature_tenths" integer, + "spo2" integer, + "blood_glucose_tenths" integer, + "weight_tenths" integer, + "notes" text DEFAULT '' NOT NULL, + "created_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 "chronic_conditions" ADD CONSTRAINT "chronic_conditions_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chronic_conditions" ADD CONSTRAINT "chronic_conditions_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "health_anomaly_reviews" ADD CONSTRAINT "health_anomaly_reviews_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "health_anomaly_reviews" ADD CONSTRAINT "health_anomaly_reviews_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "health_anomaly_reviews" ADD CONSTRAINT "health_anomaly_reviews_vital_record_id_vital_records_id_fk" FOREIGN KEY ("vital_record_id") REFERENCES "public"."vital_records"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "health_anomaly_reviews" ADD CONSTRAINT "health_anomaly_reviews_reviewed_by_account_id_accounts_id_fk" FOREIGN KEY ("reviewed_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "health_profiles" ADD CONSTRAINT "health_profiles_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "health_profiles" ADD CONSTRAINT "health_profiles_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vital_records" ADD CONSTRAINT "vital_records_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vital_records" ADD CONSTRAINT "vital_records_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vital_records" ADD CONSTRAINT "vital_records_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 +CREATE INDEX "chronic_conditions_elder_status_lookup" ON "chronic_conditions" USING btree ("organization_id","elder_id","status");--> statement-breakpoint +CREATE INDEX "health_anomaly_reviews_queue_lookup" ON "health_anomaly_reviews" USING btree ("organization_id","status","severity");--> statement-breakpoint +CREATE UNIQUE INDEX "health_profiles_organization_elder_unique" ON "health_profiles" USING btree ("organization_id","elder_id");--> statement-breakpoint +CREATE INDEX "vital_records_recent_lookup" ON "vital_records" USING btree ("organization_id","elder_id","recorded_at"); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..e906c0b --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2752 @@ +{ + "id": "24d5b494-ea3c-4f12-acf7-06223055954d", + "prevId": "336517ea-29ac-4c0d-86c2-d95c94b2eb88", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "platform_role_id": { + "name": "platform_role_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_salt": { + "name": "password_salt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_unique": { + "name": "accounts_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_platform_role_id_roles_id_fk": { + "name": "accounts_platform_role_id_roles_id_fk", + "tableFrom": "accounts", + "tableTo": "roles", + "columnsFrom": [ + "platform_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admissions": { + "name": "admissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "elder_id": { + "name": "elder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bed_id": { + "name": "bed_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "admission_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "discharged_at": { + "name": "discharged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admissions_organization_id_organizations_id_fk": { + "name": "admissions_organization_id_organizations_id_fk", + "tableFrom": "admissions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "admissions_elder_id_elders_id_fk": { + "name": "admissions_elder_id_elders_id_fk", + "tableFrom": "admissions", + "tableTo": "elders", + "columnsFrom": [ + "elder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "admissions_bed_id_beds_id_fk": { + "name": "admissions_bed_id_beds_id_fk", + "tableFrom": "admissions", + "tableTo": "beds", + "columnsFrom": [ + "bed_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "audit_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_organization_id_organizations_id_fk": { + "name": "audit_logs_organization_id_organizations_id_fk", + "tableFrom": "audit_logs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_account_id_accounts_id_fk": { + "name": "audit_logs_actor_account_id_accounts_id_fk", + "tableFrom": "audit_logs", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beds": { + "name": "beds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "bed_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "beds_code_room_unique": { + "name": "beds_code_room_unique", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beds_organization_id_organizations_id_fk": { + "name": "beds_organization_id_organizations_id_fk", + "tableFrom": "beds", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "beds_room_id_rooms_id_fk": { + "name": "beds_room_id_rooms_id_fk", + "tableFrom": "beds", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.buildings": { + "name": "buildings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "campus_id": { + "name": "campus_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_organization_id_organizations_id_fk": { + "name": "buildings_organization_id_organizations_id_fk", + "tableFrom": "buildings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "buildings_campus_id_campuses_id_fk": { + "name": "buildings_campus_id_campuses_id_fk", + "tableFrom": "buildings", + "tableTo": "campuses", + "columnsFrom": [ + "campus_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campuses": { + "name": "campuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campuses_organization_id_organizations_id_fk": { + "name": "campuses_organization_id_organizations_id_fk", + "tableFrom": "campuses", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chronic_conditions": { + "name": "chronic_conditions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "elder_id": { + "name": "elder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chronic_condition_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "diagnosed_at": { + "name": "diagnosed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "treatment_notes": { + "name": "treatment_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "follow_up_notes": { + "name": "follow_up_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chronic_conditions_elder_status_lookup": { + "name": "chronic_conditions_elder_status_lookup", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "elder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chronic_conditions_organization_id_organizations_id_fk": { + "name": "chronic_conditions_organization_id_organizations_id_fk", + "tableFrom": "chronic_conditions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chronic_conditions_elder_id_elders_id_fk": { + "name": "chronic_conditions_elder_id_elders_id_fk", + "tableFrom": "chronic_conditions", + "tableTo": "elders", + "columnsFrom": [ + "elder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.elders": { + "name": "elders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "care_level": { + "name": "care_level", + "type": "care_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "elder_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "primary_contact": { + "name": "primary_contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medical_notes": { + "name": "medical_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "elders_organization_id_organizations_id_fk": { + "name": "elders_organization_id_organizations_id_fk", + "tableFrom": "elders", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.floors": { + "name": "floors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "building_id": { + "name": "building_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "floors_organization_id_organizations_id_fk": { + "name": "floors_organization_id_organizations_id_fk", + "tableFrom": "floors", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.health_anomaly_reviews": { + "name": "health_anomaly_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "elder_id": { + "name": "elder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vital_record_id": { + "name": "vital_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "incident_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "health_review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reviewed_by_account_id": { + "name": "reviewed_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_notes": { + "name": "resolution_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "health_anomaly_reviews_queue_lookup": { + "name": "health_anomaly_reviews_queue_lookup", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "health_anomaly_reviews_organization_id_organizations_id_fk": { + "name": "health_anomaly_reviews_organization_id_organizations_id_fk", + "tableFrom": "health_anomaly_reviews", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "health_anomaly_reviews_elder_id_elders_id_fk": { + "name": "health_anomaly_reviews_elder_id_elders_id_fk", + "tableFrom": "health_anomaly_reviews", + "tableTo": "elders", + "columnsFrom": [ + "elder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "health_anomaly_reviews_vital_record_id_vital_records_id_fk": { + "name": "health_anomaly_reviews_vital_record_id_vital_records_id_fk", + "tableFrom": "health_anomaly_reviews", + "tableTo": "vital_records", + "columnsFrom": [ + "vital_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "health_anomaly_reviews_reviewed_by_account_id_accounts_id_fk": { + "name": "health_anomaly_reviews_reviewed_by_account_id_accounts_id_fk", + "tableFrom": "health_anomaly_reviews", + "tableTo": "accounts", + "columnsFrom": [ + "reviewed_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.health_profiles": { + "name": "health_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "elder_id": { + "name": "elder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allergy_notes": { + "name": "allergy_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "medical_history": { + "name": "medical_history", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "medication_notes": { + "name": "medication_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "care_restrictions": { + "name": "care_restrictions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "emergency_notes": { + "name": "emergency_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "health_profiles_organization_elder_unique": { + "name": "health_profiles_organization_elder_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "elder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "health_profiles_organization_id_organizations_id_fk": { + "name": "health_profiles_organization_id_organizations_id_fk", + "tableFrom": "health_profiles", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "health_profiles_elder_id_elders_id_fk": { + "name": "health_profiles_elder_id_elders_id_fk", + "tableFrom": "health_profiles", + "tableTo": "elders", + "columnsFrom": [ + "elder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "join_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reviewed_by_account_id": { + "name": "reviewed_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "join_requests_account_id_accounts_id_fk": { + "name": "join_requests_account_id_accounts_id_fk", + "tableFrom": "join_requests", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_organization_id_organizations_id_fk": { + "name": "join_requests_organization_id_organizations_id_fk", + "tableFrom": "join_requests", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_reviewed_by_account_id_accounts_id_fk": { + "name": "join_requests_reviewed_by_account_id_accounts_id_fk", + "tableFrom": "join_requests", + "tableTo": "accounts", + "columnsFrom": [ + "reviewed_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "membership_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_account_organization_unique": { + "name": "memberships_account_organization_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_role_id_roles_id_fk": { + "name": "memberships_role_id_roles_id_fk", + "tableFrom": "memberships", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_invitations": { + "name": "organization_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "accepted_by_account_id": { + "name": "accepted_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_invitations_token_unique": { + "name": "organization_invitations_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_invitations_organization_id_organizations_id_fk": { + "name": "organization_invitations_organization_id_organizations_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_invitations_role_id_roles_id_fk": { + "name": "organization_invitations_role_id_roles_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_invitations_created_by_account_id_accounts_id_fk": { + "name": "organization_invitations_created_by_account_id_accounts_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "created_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_invitations_accepted_by_account_id_accounts_id_fk": { + "name": "organization_invitations_accepted_by_account_id_accounts_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "accounts", + "columnsFrom": [ + "accepted_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "organization_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "registration_enabled": { + "name": "registration_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "oidc_enabled": { + "name": "oidc_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_issuer_url": { + "name": "oidc_issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidc_client_id": { + "name": "oidc_client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidc_client_secret": { + "name": "oidc_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidc_scopes": { + "name": "oidc_scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openid profile email'" + }, + "oidc_redirect_uri": { + "name": "oidc_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidc_avatar_claim": { + "name": "oidc_avatar_claim", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'picture'" + }, + "oidc_auto_provision": { + "name": "oidc_auto_provision", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "role_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_key_organization_unique": { + "name": "roles_key_organization_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_organization_id_organizations_id_fk": { + "name": "roles_organization_id_organizations_id_fk", + "tableFrom": "roles", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rooms": { + "name": "rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "floor_id": { + "name": "floor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "organization_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rooms_code_organization_unique": { + "name": "rooms_code_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rooms_organization_id_organizations_id_fk": { + "name": "rooms_organization_id_organizations_id_fk", + "tableFrom": "rooms", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_account_id_accounts_id_fk": { + "name": "sessions_account_id_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_active_organization_id_organizations_id_fk": { + "name": "sessions_active_organization_id_organizations_id_fk", + "tableFrom": "sessions", + "tableTo": "organizations", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_incidents": { + "name": "system_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "incident_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "incident_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acknowledged_by_account_id": { + "name": "acknowledged_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_account_id": { + "name": "resolved_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_incidents_organization_id_organizations_id_fk": { + "name": "system_incidents_organization_id_organizations_id_fk", + "tableFrom": "system_incidents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "system_incidents_acknowledged_by_account_id_accounts_id_fk": { + "name": "system_incidents_acknowledged_by_account_id_accounts_id_fk", + "tableFrom": "system_incidents", + "tableTo": "accounts", + "columnsFrom": [ + "acknowledged_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "system_incidents_resolved_by_account_id_accounts_id_fk": { + "name": "system_incidents_resolved_by_account_id_accounts_id_fk", + "tableFrom": "system_incidents", + "tableTo": "accounts", + "columnsFrom": [ + "resolved_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'global'" + }, + "registration_enabled": { + "name": "registration_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "oidc_enabled": { + "name": "oidc_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_issuer_url": { + "name": "oidc_issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidc_client_id": { + "name": "oidc_client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidc_client_secret": { + "name": "oidc_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidc_scopes": { + "name": "oidc_scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openid profile email'" + }, + "oidc_redirect_uri": { + "name": "oidc_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidc_avatar_claim": { + "name": "oidc_avatar_claim", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'picture'" + }, + "oidc_auto_provision": { + "name": "oidc_auto_provision", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vital_records": { + "name": "vital_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "elder_id": { + "name": "elder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "vital_record_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "systolic_bp": { + "name": "systolic_bp", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "diastolic_bp": { + "name": "diastolic_bp", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heart_rate": { + "name": "heart_rate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "temperature_tenths": { + "name": "temperature_tenths", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "spo2": { + "name": "spo2", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "blood_glucose_tenths": { + "name": "blood_glucose_tenths", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "weight_tenths": { + "name": "weight_tenths", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vital_records_recent_lookup": { + "name": "vital_records_recent_lookup", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "elder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vital_records_organization_id_organizations_id_fk": { + "name": "vital_records_organization_id_organizations_id_fk", + "tableFrom": "vital_records", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vital_records_elder_id_elders_id_fk": { + "name": "vital_records_elder_id_elders_id_fk", + "tableFrom": "vital_records", + "tableTo": "elders", + "columnsFrom": [ + "elder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vital_records_created_by_account_id_accounts_id_fk": { + "name": "vital_records_created_by_account_id_accounts_id_fk", + "tableFrom": "vital_records", + "tableTo": "accounts", + "columnsFrom": [ + "created_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_status": { + "name": "account_status", + "schema": "public", + "values": [ + "active", + "disabled", + "pending" + ] + }, + "public.admission_status": { + "name": "admission_status", + "schema": "public", + "values": [ + "active", + "transferred", + "discharged" + ] + }, + "public.audit_result": { + "name": "audit_result", + "schema": "public", + "values": [ + "success", + "denied", + "failure" + ] + }, + "public.bed_status": { + "name": "bed_status", + "schema": "public", + "values": [ + "available", + "occupied", + "maintenance", + "disabled" + ] + }, + "public.care_level": { + "name": "care_level", + "schema": "public", + "values": [ + "self-care", + "semi-assisted", + "assisted", + "intensive" + ] + }, + "public.chronic_condition_status": { + "name": "chronic_condition_status", + "schema": "public", + "values": [ + "active", + "controlled", + "resolved" + ] + }, + "public.elder_status": { + "name": "elder_status", + "schema": "public", + "values": [ + "active", + "pending", + "discharged" + ] + }, + "public.gender": { + "name": "gender", + "schema": "public", + "values": [ + "male", + "female", + "other" + ] + }, + "public.health_review_status": { + "name": "health_review_status", + "schema": "public", + "values": [ + "pending", + "reviewed", + "resolved" + ] + }, + "public.incident_severity": { + "name": "incident_severity", + "schema": "public", + "values": [ + "info", + "warning", + "critical" + ] + }, + "public.incident_status": { + "name": "incident_status", + "schema": "public", + "values": [ + "open", + "acknowledged", + "resolved", + "closed" + ] + }, + "public.join_request_status": { + "name": "join_request_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.membership_status": { + "name": "membership_status", + "schema": "public", + "values": [ + "active", + "disabled", + "pending" + ] + }, + "public.organization_status": { + "name": "organization_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.role_scope": { + "name": "role_scope", + "schema": "public", + "values": [ + "platform", + "organization" + ] + }, + "public.vital_record_source": { + "name": "vital_record_source", + "schema": "public", + "values": [ + "manual", + "device", + "import" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2c98746..062132c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1782998189463, "tag": "0002_equal_bishop", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1783063091807, + "tag": "0003_gray_dust", + "breakpoints": true } ] } \ No newline at end of file diff --git a/modules/core/server/auth.ts b/modules/core/server/auth.ts index a570455..7f40dcd 100644 --- a/modules/core/server/auth.ts +++ b/modules/core/server/auth.ts @@ -5,6 +5,7 @@ import { cookies } from "next/headers"; import { jsonFailure } from "@/modules/core/server/api"; import { recordAuditLog } from "@/modules/core/server/audit"; +import { seedDefaultWorkspaceData } from "@/modules/core/server/default-workspace-data"; import { getDatabase } from "@/modules/core/server/db"; import { getPermissionsForRole, hasPermission, seedOrganizationRoles } from "@/modules/core/server/permissions"; import { @@ -275,6 +276,7 @@ export async function setupFirstPlatformAdmin(input: { }) .onConflictDoNothing(); } + await seedDefaultWorkspaceData(created.organization.id); const organization = toOrganization(created.organization); const publicAccount = toPublicAccount(created.account, created.platformRole.key, organization); diff --git a/modules/core/server/default-workspace-data.ts b/modules/core/server/default-workspace-data.ts new file mode 100644 index 0000000..16c6eff --- /dev/null +++ b/modules/core/server/default-workspace-data.ts @@ -0,0 +1,677 @@ +import { eq } from "drizzle-orm"; + +import { getDatabase } from "@/modules/core/server/db"; +import { + admissions, + beds, + buildings, + campuses, + chronicConditions, + elders, + floors, + healthAnomalyReviews, + healthProfiles, + rooms, + systemIncidents, + vitalRecords, +} from "@/modules/core/server/schema"; + +type SeedRoom = { + buildingName: string; + campusName: string; + code: string; + floorLevel: number; + floorName: string; + name: string; + capacity: number; +}; + +type SeedBed = { + code: string; + notes: string; + roomCode: string; + status: "available" | "occupied" | "maintenance" | "disabled"; +}; + +type SeedElder = { + age: number; + careLevel: "self-care" | "semi-assisted" | "assisted" | "intensive"; + gender: "male" | "female" | "other"; + medicalNotes: string; + name: string; + phone: string; + primaryContact: string; + status: "active" | "pending" | "discharged"; +}; + +type SeedAdmission = { + bedCode: string; + elderName: string; + notes: string; + status: "active" | "transferred" | "discharged"; +}; + +type SeedHealthProfile = { + allergyNotes: string; + careRestrictions: string; + elderName: string; + emergencyNotes: string; + medicalHistory: string; + medicationNotes: string; +}; + +type SeedVitalRecord = { + bloodGlucoseTenths?: number; + diastolicBp?: number; + elderName: string; + heartRate?: number; + notes: string; + recordedHoursAgo: number; + source: "manual" | "device" | "import"; + spo2?: number; + systolicBp?: number; + temperatureTenths?: number; + weightTenths?: number; +}; + +type SeedChronicCondition = { + elderName: string; + followUpNotes: string; + name: string; + status: "active" | "controlled" | "resolved"; + treatmentNotes: string; +}; + +type SeedHealthReview = { + description: string; + elderName: string; + resolutionNotes: string; + severity: "info" | "warning" | "critical"; + status: "pending" | "reviewed" | "resolved"; + title: string; + vitalIndex: number; +}; + +const DEFAULT_ROOMS: SeedRoom[] = [ + { campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 }, + { campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 }, + { campusName: "主院区", buildingName: "护理楼", floorName: "二层", floorLevel: 2, code: "A201", name: "记忆照护房", capacity: 2 }, + { campusName: "主院区", buildingName: "护理楼", floorName: "二层", floorLevel: 2, code: "A202", name: "重点照护房", capacity: 2 }, + { campusName: "主院区", buildingName: "康复楼", floorName: "一层", floorLevel: 1, code: "R101", name: "康复观察房", capacity: 2 }, + { campusName: "主院区", buildingName: "康复楼", floorName: "一层", floorLevel: 1, code: "R102", name: "短住评估房", capacity: 2 }, + { campusName: "北苑院区", buildingName: "颐养楼", floorName: "一层", floorLevel: 1, code: "B101", name: "自理房", capacity: 2 }, + { campusName: "北苑院区", buildingName: "颐养楼", floorName: "二层", floorLevel: 2, code: "B201", name: "亲情套房", capacity: 2 }, +]; + +const DEFAULT_BEDS: SeedBed[] = [ + { code: "A101-1", roomCode: "A101", status: "occupied", notes: "靠窗,已完成入住评估" }, + { code: "A101-2", roomCode: "A101", status: "available", notes: "可安排半护理老人入住" }, + { code: "A102-1", roomCode: "A102", status: "occupied", notes: "夜间巡护重点关注" }, + { code: "A102-2", roomCode: "A102", status: "maintenance", notes: "床头呼叫器检修中" }, + { code: "A201-1", roomCode: "A201", status: "occupied", notes: "记忆照护专区" }, + { code: "A201-2", roomCode: "A201", status: "available", notes: "靠近护理站" }, + { code: "A202-1", roomCode: "A202", status: "occupied", notes: "压疮风险重点巡查" }, + { code: "A202-2", roomCode: "A202", status: "disabled", notes: "待更换护栏" }, + { code: "R101-1", roomCode: "R101", status: "occupied", notes: "康复训练后休息床位" }, + { code: "R101-2", roomCode: "R101", status: "available", notes: "临时观察床位" }, + { code: "R102-1", roomCode: "R102", status: "available", notes: "短住评估床位" }, + { code: "R102-2", roomCode: "R102", status: "available", notes: "短住评估床位" }, + { code: "B101-1", roomCode: "B101", status: "occupied", notes: "自理老人长期床位" }, + { code: "B101-2", roomCode: "B101", status: "available", notes: "低照护需求床位" }, + { code: "B201-1", roomCode: "B201", status: "occupied", notes: "亲属探访便利" }, + { code: "B201-2", roomCode: "B201", status: "available", notes: "亲情套房备用床" }, +]; + +const DEFAULT_ELDERS: SeedElder[] = [ + { + name: "王桂兰", + gender: "female", + age: 82, + careLevel: "intensive", + status: "active", + primaryContact: "张敏", + phone: "13800000001", + medicalNotes: "高血压,需晨晚血压记录。", + }, + { + name: "李建国", + gender: "male", + age: 76, + careLevel: "semi-assisted", + status: "active", + primaryContact: "李明", + phone: "13800000002", + medicalNotes: "餐后散步陪同,关注睡眠情况。", + }, + { + name: "周玉珍", + gender: "female", + age: 88, + careLevel: "assisted", + status: "active", + primaryContact: "周强", + phone: "13800000003", + medicalNotes: "糖尿病饮食,餐前血糖提醒。", + }, + { + name: "赵国华", + gender: "male", + age: 91, + careLevel: "intensive", + status: "active", + primaryContact: "赵琳", + phone: "13800000004", + medicalNotes: "夜间翻身,两小时巡房。", + }, + { + name: "孙秀梅", + gender: "female", + age: 73, + careLevel: "semi-assisted", + status: "active", + primaryContact: "孙磊", + phone: "13800000005", + medicalNotes: "膝关节康复训练,每日下午一次。", + }, + { + name: "刘长青", + gender: "male", + age: 68, + careLevel: "self-care", + status: "active", + primaryContact: "刘洋", + phone: "13800000006", + medicalNotes: "自理,需定期复查用药。", + }, + { + name: "陈秀英", + gender: "female", + age: 69, + careLevel: "self-care", + status: "pending", + primaryContact: "陈佳", + phone: "13800000007", + medicalNotes: "待完成入住评估。", + }, + { + name: "何德明", + gender: "male", + age: 84, + careLevel: "assisted", + status: "pending", + primaryContact: "何静", + phone: "13800000008", + medicalNotes: "家属已提交体检资料,等待床位确认。", + }, + { + name: "吴佩兰", + gender: "female", + age: 79, + careLevel: "semi-assisted", + status: "discharged", + primaryContact: "吴昊", + phone: "13800000009", + medicalNotes: "已退住,保留历史档案。", + }, + { + name: "郑文远", + gender: "male", + age: 72, + careLevel: "self-care", + status: "discharged", + primaryContact: "郑洁", + phone: "13800000010", + medicalNotes: "短住康复结束后退住。", + }, +]; + +const DEFAULT_ADMISSIONS: SeedAdmission[] = [ + { elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "默认工作区入住记录" }, + { elderName: "李建国", bedCode: "A102-1", status: "active", notes: "默认工作区入住记录" }, + { elderName: "周玉珍", bedCode: "A201-1", status: "active", notes: "记忆照护专区入住" }, + { elderName: "赵国华", bedCode: "A202-1", status: "active", notes: "重点照护入住" }, + { elderName: "孙秀梅", bedCode: "R101-1", status: "active", notes: "康复观察入住" }, + { elderName: "刘长青", bedCode: "B101-1", status: "active", notes: "自理区入住" }, + { elderName: "吴佩兰", bedCode: "B201-1", status: "discharged", notes: "历史退住记录" }, +]; + +const DEFAULT_HEALTH_PROFILES: SeedHealthProfile[] = [ + { + elderName: "王桂兰", + allergyNotes: "青霉素过敏", + medicalHistory: "高血压十余年,偶发胸闷。", + medicationNotes: "晨服降压药,晚间复核血压。", + careRestrictions: "低盐饮食,避免剧烈活动。", + emergencyNotes: "胸闷或血压持续升高时立即联系医生。", + }, + { + elderName: "周玉珍", + allergyNotes: "无明确药物过敏史", + medicalHistory: "2 型糖尿病,轻度认知障碍。", + medicationNotes: "餐前血糖监测,按医嘱用药。", + careRestrictions: "控糖饮食,夜间加强巡护。", + emergencyNotes: "低血糖症状时立即补糖并记录。", + }, + { + elderName: "赵国华", + allergyNotes: "海鲜过敏", + medicalHistory: "脑梗后遗症,长期卧床风险。", + medicationNotes: "抗凝药按时服用。", + careRestrictions: "两小时翻身,防跌倒,防压疮。", + emergencyNotes: "意识异常或血氧下降时立即启动应急流程。", + }, + { + elderName: "刘长青", + allergyNotes: "", + medicalHistory: "冠心病随访。", + medicationNotes: "自主管理用药,护理员每日提醒。", + careRestrictions: "避免爬楼和长时间站立。", + emergencyNotes: "胸痛需立即报告。", + }, +]; + +const DEFAULT_VITAL_RECORDS: SeedVitalRecord[] = [ + { + elderName: "王桂兰", + recordedHoursAgo: 2, + source: "manual", + systolicBp: 158, + diastolicBp: 96, + heartRate: 88, + temperatureTenths: 367, + spo2: 96, + bloodGlucoseTenths: 62, + weightTenths: 612, + notes: "血压偏高,已安排复测。", + }, + { + elderName: "李建国", + recordedHoursAgo: 5, + source: "device", + systolicBp: 128, + diastolicBp: 78, + heartRate: 76, + temperatureTenths: 365, + spo2: 98, + notes: "体征平稳。", + }, + { + elderName: "周玉珍", + recordedHoursAgo: 7, + source: "manual", + systolicBp: 134, + diastolicBp: 82, + heartRate: 80, + temperatureTenths: 366, + spo2: 97, + bloodGlucoseTenths: 118, + notes: "餐后血糖偏高,已记录饮食。", + }, + { + elderName: "赵国华", + recordedHoursAgo: 12, + source: "manual", + systolicBp: 146, + diastolicBp: 88, + heartRate: 92, + temperatureTenths: 381, + spo2: 91, + notes: "体温和血氧异常,需复核。", + }, +]; + +const DEFAULT_CHRONIC_CONDITIONS: SeedChronicCondition[] = [ + { + elderName: "王桂兰", + name: "高血压", + status: "active", + treatmentNotes: "规律服药,晨晚血压监测。", + followUpNotes: "每周汇总血压趋势。", + }, + { + elderName: "周玉珍", + name: "糖尿病", + status: "active", + treatmentNotes: "控糖饮食,餐前餐后血糖记录。", + followUpNotes: "异常血糖进入复核队列。", + }, + { + elderName: "刘长青", + name: "冠心病", + status: "controlled", + treatmentNotes: "按医嘱服药,避免劳累。", + followUpNotes: "月度复诊提醒。", + }, +]; + +const DEFAULT_HEALTH_REVIEWS: SeedHealthReview[] = [ + { + elderName: "王桂兰", + vitalIndex: 0, + severity: "warning", + status: "pending", + title: "血压异常", + description: "收缩压和舒张压偏高,需要复测并确认用药。", + resolutionNotes: "", + }, + { + elderName: "赵国华", + vitalIndex: 3, + severity: "critical", + status: "pending", + title: "血氧和体温异常", + description: "血氧低于阈值且体温偏高,需要立即复核。", + resolutionNotes: "", + }, + { + elderName: "周玉珍", + vitalIndex: 2, + severity: "info", + status: "reviewed", + title: "餐后血糖偏高", + description: "餐后血糖高于日常水平。", + resolutionNotes: "已调整晚餐主食并安排次日复测。", + }, +]; + +function hasRows(rows: unknown[]): boolean { + return rows.length > 0; +} + +export async function seedDefaultWorkspaceData(organizationId: string): Promise { + const database = getDatabase(); + const [existingRooms, existingBeds, existingElders, existingAdmissions] = await Promise.all([ + database.select({ id: rooms.id }).from(rooms).where(eq(rooms.organizationId, organizationId)).limit(1), + database.select({ id: beds.id }).from(beds).where(eq(beds.organizationId, organizationId)).limit(1), + database.select({ id: elders.id }).from(elders).where(eq(elders.organizationId, organizationId)).limit(1), + database.select({ id: admissions.id }).from(admissions).where(eq(admissions.organizationId, organizationId)).limit(1), + ]); + + if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) { + return; + } + + await database.transaction(async (transaction) => { + const uniqueCampusNames = Array.from(new Set(DEFAULT_ROOMS.map((room) => room.campusName))); + const campusRows = await transaction + .insert(campuses) + .values( + uniqueCampusNames.map((name) => ({ + organizationId, + name, + address: `${name}养老服务院区`, + })), + ) + .returning(); + if (campusRows.length !== uniqueCampusNames.length) { + throw new Error("默认院区初始化失败"); + } + const campusIdByName = new Map(campusRows.map((campus) => [campus.name, campus.id])); + + const uniqueBuildings = Array.from( + new Map(DEFAULT_ROOMS.map((room) => [`${room.campusName}/${room.buildingName}`, room])).values(), + ); + const buildingRows = await transaction + .insert(buildings) + .values( + uniqueBuildings.map((building) => { + const campusId = campusIdByName.get(building.campusName); + if (!campusId) { + throw new Error("默认楼栋初始化失败"); + } + + return { + organizationId, + campusId, + name: building.buildingName, + }; + }), + ) + .returning(); + if (buildingRows.length !== uniqueBuildings.length) { + throw new Error("默认楼栋初始化失败"); + } + const buildingIdByKey = new Map( + buildingRows.map((building, index) => { + const source = uniqueBuildings[index]; + if (!source) { + throw new Error("默认楼栋初始化失败"); + } + + return [`${source.campusName}/${source.buildingName}`, building.id]; + }), + ); + + const uniqueFloors = Array.from( + new Map( + DEFAULT_ROOMS.map((room) => [ + `${room.campusName}/${room.buildingName}/${room.floorLevel}`, + room, + ]), + ).values(), + ); + const floorRows = await transaction + .insert(floors) + .values( + uniqueFloors.map((floor) => { + const buildingId = buildingIdByKey.get(`${floor.campusName}/${floor.buildingName}`); + if (!buildingId) { + throw new Error("默认楼层初始化失败"); + } + + return { + organizationId, + buildingId, + name: floor.floorName, + level: floor.floorLevel, + }; + }), + ) + .returning(); + if (floorRows.length !== uniqueFloors.length) { + throw new Error("默认楼层初始化失败"); + } + const floorIdByKey = new Map( + floorRows.map((floor, index) => { + const source = uniqueFloors[index]; + if (!source) { + throw new Error("默认楼层初始化失败"); + } + + return [`${source.campusName}/${source.buildingName}/${source.floorLevel}`, floor.id]; + }), + ); + + const roomRows = await transaction + .insert(rooms) + .values( + DEFAULT_ROOMS.map((room) => { + const floorId = floorIdByKey.get(`${room.campusName}/${room.buildingName}/${room.floorLevel}`); + if (!floorId) { + throw new Error("默认房间初始化失败"); + } + + return { + organizationId, + floorId, + name: room.name, + code: room.code, + capacity: room.capacity, + }; + }), + ) + .returning(); + if (roomRows.length !== DEFAULT_ROOMS.length) { + throw new Error("默认房间初始化失败"); + } + + const roomIdByCode = new Map(roomRows.map((room) => [room.code, room.id])); + const bedRows = await transaction + .insert(beds) + .values( + DEFAULT_BEDS.map((bed) => { + const roomId = roomIdByCode.get(bed.roomCode); + if (!roomId) { + throw new Error("默认床位初始化失败"); + } + + return { + organizationId, + roomId, + code: bed.code, + status: bed.status, + notes: bed.notes, + }; + }), + ) + .returning(); + if (bedRows.length !== DEFAULT_BEDS.length) { + throw new Error("默认床位初始化失败"); + } + + const elderRows = await transaction + .insert(elders) + .values(DEFAULT_ELDERS.map((elder) => ({ ...elder, organizationId }))) + .returning(); + if (elderRows.length !== DEFAULT_ELDERS.length) { + throw new Error("默认老人档案初始化失败"); + } + + const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id])); + const bedIdByCode = new Map(bedRows.map((bed) => [bed.code, bed.id])); + await transaction.insert(admissions).values( + DEFAULT_ADMISSIONS.map((admission) => { + const elderId = elderIdByName.get(admission.elderName); + const bedId = bedIdByCode.get(admission.bedCode); + if (!elderId || !bedId) { + throw new Error("默认入住初始化失败"); + } + + return { + organizationId, + elderId, + bedId, + status: admission.status, + dischargedAt: admission.status === "discharged" ? new Date() : undefined, + notes: admission.notes, + }; + }), + ); + + await transaction.insert(healthProfiles).values( + DEFAULT_HEALTH_PROFILES.map((profile) => { + const elderId = elderIdByName.get(profile.elderName); + if (!elderId) { + throw new Error("默认健康档案初始化失败"); + } + + return { + organizationId, + elderId, + allergyNotes: profile.allergyNotes, + medicalHistory: profile.medicalHistory, + medicationNotes: profile.medicationNotes, + careRestrictions: profile.careRestrictions, + emergencyNotes: profile.emergencyNotes, + }; + }), + ); + + const now = new Date(); + const vitalRows = await transaction + .insert(vitalRecords) + .values( + DEFAULT_VITAL_RECORDS.map((vital) => { + const elderId = elderIdByName.get(vital.elderName); + if (!elderId) { + throw new Error("默认生命体征初始化失败"); + } + + return { + organizationId, + elderId, + recordedAt: new Date(now.getTime() - vital.recordedHoursAgo * 60 * 60 * 1000), + source: vital.source, + systolicBp: vital.systolicBp, + diastolicBp: vital.diastolicBp, + heartRate: vital.heartRate, + temperatureTenths: vital.temperatureTenths, + spo2: vital.spo2, + bloodGlucoseTenths: vital.bloodGlucoseTenths, + weightTenths: vital.weightTenths, + notes: vital.notes, + }; + }), + ) + .returning(); + if (vitalRows.length !== DEFAULT_VITAL_RECORDS.length) { + throw new Error("默认生命体征初始化失败"); + } + + await transaction.insert(chronicConditions).values( + DEFAULT_CHRONIC_CONDITIONS.map((condition) => { + const elderId = elderIdByName.get(condition.elderName); + if (!elderId) { + throw new Error("默认慢病记录初始化失败"); + } + + return { + organizationId, + elderId, + name: condition.name, + status: condition.status, + treatmentNotes: condition.treatmentNotes, + followUpNotes: condition.followUpNotes, + }; + }), + ); + + await transaction.insert(healthAnomalyReviews).values( + DEFAULT_HEALTH_REVIEWS.map((review) => { + const elderId = elderIdByName.get(review.elderName); + const vital = vitalRows[review.vitalIndex]; + if (!elderId || !vital) { + throw new Error("默认健康异常复核初始化失败"); + } + + return { + organizationId, + elderId, + vitalRecordId: vital.id, + severity: review.severity, + status: review.status, + title: review.title, + description: review.description, + resolutionNotes: review.resolutionNotes, + }; + }), + ); + + await transaction.insert(systemIncidents).values([ + { + organizationId, + severity: "warning", + status: "open", + title: "床头呼叫器待检修", + description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。", + source: "默认工作区初始化", + }, + { + organizationId, + severity: "info", + status: "acknowledged", + title: "本周护理评估待完成", + description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。", + source: "默认工作区初始化", + }, + { + organizationId, + severity: "critical", + status: "resolved", + title: "夜间巡房异常已处理", + description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。", + source: "默认工作区初始化", + }, + ]); + }); +} diff --git a/modules/core/server/permissions.ts b/modules/core/server/permissions.ts index 7fc1f2c..78a6787 100644 --- a/modules/core/server/permissions.ts +++ b/modules/core/server/permissions.ts @@ -25,6 +25,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [ { id: "audit:read", label: "查看审计", category: "审计", description: "查看关键操作审计日志。" }, { id: "incident:read", label: "查看故障", category: "运维", description: "查看系统健康和故障中心。" }, { id: "incident:manage", label: "处理故障", category: "运维", description: "确认、处理和关闭故障事件。" }, + { id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" }, + { id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" }, { id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" }, { id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" }, { id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" }, @@ -89,6 +91,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [ "permission:read", "audit:read", "incident:read", + "health:read", + "health:manage", "facility:read", "facility:manage", "admission:read", @@ -107,6 +111,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [ "account:read", "role:read", "audit:read", + "health:read", + "health:manage", "facility:read", "facility:manage", "admission:read", @@ -121,13 +127,13 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [ key: "caregiver", scope: "organization", description: "照护人员,查看床位和维护老人照护信息。", - permissions: ["facility:read", "admission:read", "elder:read", "elder:update"], + permissions: ["health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"], }, { key: "viewer", scope: "organization", description: "普通用户,只读查看老人、床位和入住信息。", - permissions: ["facility:read", "admission:read", "elder:read"], + permissions: ["health:read", "facility:read", "admission:read", "elder:read"], }, { key: "family", diff --git a/modules/core/server/schema.ts b/modules/core/server/schema.ts index 9630d93..9a5929b 100644 --- a/modules/core/server/schema.ts +++ b/modules/core/server/schema.ts @@ -1,5 +1,6 @@ import { boolean, + index, integer, pgEnum, pgTable, @@ -23,6 +24,9 @@ export const auditResultEnum = pgEnum("audit_result", ["success", "denied", "fai export const incidentStatusEnum = pgEnum("incident_status", ["open", "acknowledged", "resolved", "closed"]); export const incidentSeverityEnum = pgEnum("incident_severity", ["info", "warning", "critical"]); export const joinRequestStatusEnum = pgEnum("join_request_status", ["pending", "approved", "rejected"]); +export const vitalRecordSourceEnum = pgEnum("vital_record_source", ["manual", "device", "import"]); +export const chronicConditionStatusEnum = pgEnum("chronic_condition_status", ["active", "controlled", "resolved"]); +export const healthReviewStatusEnum = pgEnum("health_review_status", ["pending", "reviewed", "resolved"]); export const systemSettings = pgTable("system_settings", { id: text("id").primaryKey().default("global"), @@ -226,6 +230,75 @@ export const elders = pgTable("elders", { updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); +export const healthProfiles = pgTable("health_profiles", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }), + allergyNotes: text("allergy_notes").notNull().default(""), + medicalHistory: text("medical_history").notNull().default(""), + medicationNotes: text("medication_notes").notNull().default(""), + careRestrictions: text("care_restrictions").notNull().default(""), + emergencyNotes: text("emergency_notes").notNull().default(""), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + elderUnique: uniqueIndex("health_profiles_organization_elder_unique").on(table.organizationId, table.elderId), +})); + +export const vitalRecords = pgTable("vital_records", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }), + recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), + source: vitalRecordSourceEnum("source").notNull().default("manual"), + systolicBp: integer("systolic_bp"), + diastolicBp: integer("diastolic_bp"), + heartRate: integer("heart_rate"), + temperatureTenths: integer("temperature_tenths"), + spo2: integer("spo2"), + bloodGlucoseTenths: integer("blood_glucose_tenths"), + weightTenths: integer("weight_tenths"), + notes: text("notes").notNull().default(""), + createdByAccountId: uuid("created_by_account_id").references(() => accounts.id), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + recentLookup: index("vital_records_recent_lookup").on(table.organizationId, table.elderId, table.recordedAt), +})); + +export const chronicConditions = pgTable("chronic_conditions", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }), + name: text("name").notNull(), + status: chronicConditionStatusEnum("status").notNull().default("active"), + diagnosedAt: timestamp("diagnosed_at", { withTimezone: true }), + treatmentNotes: text("treatment_notes").notNull().default(""), + followUpNotes: text("follow_up_notes").notNull().default(""), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + elderStatusLookup: index("chronic_conditions_elder_status_lookup").on(table.organizationId, table.elderId, table.status), +})); + +export const healthAnomalyReviews = pgTable("health_anomaly_reviews", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }), + vitalRecordId: uuid("vital_record_id").references(() => vitalRecords.id, { onDelete: "set null" }), + severity: incidentSeverityEnum("severity").notNull(), + status: healthReviewStatusEnum("status").notNull().default("pending"), + title: text("title").notNull(), + description: text("description").notNull(), + reviewedByAccountId: uuid("reviewed_by_account_id").references(() => accounts.id), + reviewedAt: timestamp("reviewed_at", { withTimezone: true }), + resolutionNotes: text("resolution_notes").notNull().default(""), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + reviewQueueLookup: index("health_anomaly_reviews_queue_lookup").on(table.organizationId, table.status, table.severity), +})); + export const admissions = pgTable("admissions", { id: uuid("id").primaryKey().defaultRandom(), organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), diff --git a/modules/core/types.ts b/modules/core/types.ts index 74b2844..cff2ca3 100644 --- a/modules/core/types.ts +++ b/modules/core/types.ts @@ -39,6 +39,8 @@ export const PERMISSIONS = [ "audit:read", "incident:read", "incident:manage", + "health:read", + "health:manage", "facility:read", "facility:manage", "admission:read", diff --git a/modules/health/components/HealthAdminClient.tsx b/modules/health/components/HealthAdminClient.tsx new file mode 100644 index 0000000..12d2ab6 --- /dev/null +++ b/modules/health/components/HealthAdminClient.tsx @@ -0,0 +1,822 @@ +"use client"; + +import { FormEvent, useMemo, useState } from "react"; +import { Activity, ClipboardPlus, FileText, HeartPulse, Search, ShieldAlert, Stethoscope } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog } from "@/components/ui/dialog"; +import { Input, Textarea } from "@/components/ui/input"; +import { Select, type SelectOption } from "@/components/ui/select"; +import { Table } from "@/components/ui/table"; +import type { ApiResult } from "@/modules/core/server/api"; +import type { + ChronicCondition, + ChronicConditionStatus, + HealthAdminData, + HealthAnomalyReview, + HealthProfile, + HealthReviewSeverity, + HealthReviewStatus, + VitalRecord, + VitalSource, +} from "@/modules/health/types"; +import { + CHRONIC_CONDITION_STATUS_LABELS, + HEALTH_REVIEW_SEVERITY_LABELS, + HEALTH_REVIEW_STATUS_LABELS, + VITAL_SOURCE_LABELS, +} from "@/modules/health/types"; + +type HealthAdminClientProps = { + canManage: boolean; + initialData: HealthAdminData; +}; + +type HealthTab = "profiles" | "vitals" | "conditions" | "reviews"; + +type ProfileForm = { + allergyNotes: string; + medicalHistory: string; + medicationNotes: string; + careRestrictions: string; + emergencyNotes: string; +}; + +const tabs: Array<{ value: HealthTab; label: string }> = [ + { value: "profiles", label: "健康档案" }, + { value: "vitals", label: "生命体征" }, + { value: "conditions", label: "慢病记录" }, + { value: "reviews", label: "异常复核" }, +]; + +const emptyProfileForm: ProfileForm = { + allergyNotes: "", + medicalHistory: "", + medicationNotes: "", + careRestrictions: "", + emergencyNotes: "", +}; + +const emptyVitalForm = { + elderId: "", + recordedAt: "", + source: "manual" as VitalSource, + systolicBp: "", + diastolicBp: "", + heartRate: "", + temperatureTenths: "", + spo2: "", + bloodGlucoseTenths: "", + weightTenths: "", + notes: "", +}; + +const emptyConditionForm = { + elderId: "", + name: "", + status: "active" as ChronicConditionStatus, + diagnosedAt: "", + treatmentNotes: "", + followUpNotes: "", +}; + +function formatDateTime(value: string | undefined): string { + return value ? new Date(value).toLocaleString("zh-CN") : "-"; +} + +function formatTenths(value: number | undefined, suffix = ""): string { + return value === undefined ? "-" : `${(value / 10).toFixed(1)}${suffix}`; +} + +function optionalNumber(value: string): number | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const numeric = Number(trimmed); + return Number.isFinite(numeric) ? numeric : undefined; +} + +function optionalTenths(value: string): number | undefined { + const numeric = optionalNumber(value); + return numeric === undefined ? undefined : Math.round(numeric * 10); +} + +function profileToForm(profile: HealthProfile | undefined): ProfileForm { + return profile + ? { + allergyNotes: profile.allergyNotes, + medicalHistory: profile.medicalHistory, + medicationNotes: profile.medicationNotes, + careRestrictions: profile.careRestrictions, + emergencyNotes: profile.emergencyNotes, + } + : emptyProfileForm; +} + +function severityBadgeVariant(severity: HealthReviewSeverity): "secondary" | "warning" | "danger" { + if (severity === "critical") { + return "danger"; + } + + return severity === "warning" ? "warning" : "secondary"; +} + +function reviewStatusBadgeVariant(status: HealthReviewStatus): "success" | "secondary" | "warning" { + if (status === "pending") { + return "warning"; + } + + return status === "resolved" ? "success" : "secondary"; +} + +export function HealthAdminClient({ canManage, initialData }: HealthAdminClientProps): React.ReactElement { + const [data, setData] = useState(initialData); + const [activeTab, setActiveTab] = useState("profiles"); + const [query, setQuery] = useState(""); + const [vitalSourceFilter, setVitalSourceFilter] = useState<"all" | VitalSource>("all"); + const [conditionStatusFilter, setConditionStatusFilter] = useState<"all" | ChronicConditionStatus>("all"); + const [reviewStatusFilter, setReviewStatusFilter] = useState<"all" | HealthReviewStatus>("all"); + const [reviewSeverityFilter, setReviewSeverityFilter] = useState<"all" | HealthReviewSeverity>("all"); + const [message, setMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + const [editingProfile, setEditingProfile] = useState(); + const [profileElderId, setProfileElderId] = useState(""); + const [profileForm, setProfileForm] = useState(emptyProfileForm); + const [isVitalOpen, setIsVitalOpen] = useState(false); + const [vitalForm, setVitalForm] = useState(emptyVitalForm); + const [isConditionOpen, setIsConditionOpen] = useState(false); + const [conditionForm, setConditionForm] = useState(emptyConditionForm); + const [reviewTarget, setReviewTarget] = useState(); + const [reviewStatus, setReviewStatus] = useState("reviewed"); + const [reviewNotes, setReviewNotes] = useState(""); + + const elderOptions: SelectOption[] = useMemo( + () => [ + { value: "", label: "选择老人", disabled: true }, + ...data.elders.map((elder) => ({ value: elder.id, label: elder.name })), + ], + [data.elders], + ); + const normalizedQuery = query.trim().toLowerCase(); + const profilesByElderId = useMemo( + () => new Map(data.profiles.map((profile) => [profile.elderId, profile])), + [data.profiles], + ); + const filteredElders = useMemo( + () => + data.elders.filter((elder) => { + if (!normalizedQuery) { + return true; + } + + const profile = profilesByElderId.get(elder.id); + return [elder.name, profile?.allergyNotes, profile?.medicalHistory, profile?.emergencyNotes] + .join(" ") + .toLowerCase() + .includes(normalizedQuery); + }), + [data.elders, normalizedQuery, profilesByElderId], + ); + const filteredVitals = useMemo( + () => + data.vitals.filter((vital) => { + const matchesQuery = !normalizedQuery || vital.elderName.toLowerCase().includes(normalizedQuery); + const matchesSource = vitalSourceFilter === "all" || vital.source === vitalSourceFilter; + return matchesQuery && matchesSource; + }), + [data.vitals, normalizedQuery, vitalSourceFilter], + ); + const filteredConditions = useMemo( + () => + data.chronicConditions.filter((condition) => { + const matchesQuery = !normalizedQuery + ? true + : [condition.elderName, condition.name].join(" ").toLowerCase().includes(normalizedQuery); + const matchesStatus = conditionStatusFilter === "all" || condition.status === conditionStatusFilter; + return matchesQuery && matchesStatus; + }), + [conditionStatusFilter, data.chronicConditions, normalizedQuery], + ); + const filteredReviews = useMemo( + () => + data.reviews.filter((review) => { + const matchesQuery = !normalizedQuery + ? true + : [review.elderName, review.title, review.description].join(" ").toLowerCase().includes(normalizedQuery); + const matchesStatus = reviewStatusFilter === "all" || review.status === reviewStatusFilter; + const matchesSeverity = reviewSeverityFilter === "all" || review.severity === reviewSeverityFilter; + return matchesQuery && matchesStatus && matchesSeverity; + }), + [data.reviews, normalizedQuery, reviewSeverityFilter, reviewStatusFilter], + ); + + async function refreshData(): Promise { + const response = await fetch("/api/health/admin"); + const result = (await response.json()) as ApiResult<{ data: HealthAdminData }>; + if (result.success) { + setData(result.data); + } + } + + function openProfileDialog(elderId: string): void { + const profile = profilesByElderId.get(elderId); + setEditingProfile(profile); + setProfileElderId(elderId); + setProfileForm(profileToForm(profile)); + setMessage(""); + } + + function closeProfileDialog(): void { + if (isPending) { + return; + } + + setEditingProfile(undefined); + setProfileElderId(""); + setProfileForm(emptyProfileForm); + } + + async function saveProfile(event: FormEvent): Promise { + event.preventDefault(); + if (!canManage || !profileElderId) { + setMessage("当前角色无权维护健康档案"); + return; + } + + setIsPending(true); + setMessage(""); + const response = await fetch(`/api/health/profiles/${profileElderId}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(profileForm), + }); + const result = (await response.json()) as ApiResult<{ profile: HealthProfile }>; + setIsPending(false); + setMessage(result.reason); + + if (result.success) { + closeProfileDialog(); + await refreshData(); + } + } + + async function saveVital(event: FormEvent): Promise { + event.preventDefault(); + if (!canManage) { + setMessage("当前角色无权录入生命体征"); + return; + } + + setIsPending(true); + setMessage(""); + const response = await fetch("/api/health/vitals", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + elderId: vitalForm.elderId, + recordedAt: vitalForm.recordedAt, + source: vitalForm.source, + systolicBp: optionalNumber(vitalForm.systolicBp), + diastolicBp: optionalNumber(vitalForm.diastolicBp), + heartRate: optionalNumber(vitalForm.heartRate), + temperatureTenths: optionalTenths(vitalForm.temperatureTenths), + spo2: optionalNumber(vitalForm.spo2), + bloodGlucoseTenths: optionalTenths(vitalForm.bloodGlucoseTenths), + weightTenths: optionalTenths(vitalForm.weightTenths), + notes: vitalForm.notes, + }), + }); + const result = (await response.json()) as ApiResult<{ vital: VitalRecord; review?: HealthAnomalyReview }>; + setIsPending(false); + setMessage(result.reason); + + if (result.success) { + setIsVitalOpen(false); + setVitalForm(emptyVitalForm); + await refreshData(); + } + } + + async function saveCondition(event: FormEvent): Promise { + event.preventDefault(); + if (!canManage) { + setMessage("当前角色无权新增慢病记录"); + return; + } + + setIsPending(true); + setMessage(""); + const response = await fetch("/api/health/chronic-conditions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(conditionForm), + }); + const result = (await response.json()) as ApiResult<{ condition: ChronicCondition }>; + setIsPending(false); + setMessage(result.reason); + + if (result.success) { + setIsConditionOpen(false); + setConditionForm(emptyConditionForm); + await refreshData(); + } + } + + function openReviewDialog(review: HealthAnomalyReview): void { + setReviewTarget(review); + setReviewStatus(review.status === "pending" ? "reviewed" : review.status); + setReviewNotes(review.resolutionNotes); + setMessage(""); + } + + function closeReviewDialog(): void { + if (isPending) { + return; + } + + setReviewTarget(undefined); + setReviewStatus("reviewed"); + setReviewNotes(""); + } + + async function saveReview(event: FormEvent): Promise { + event.preventDefault(); + if (!canManage || !reviewTarget) { + setMessage("当前角色无权处理异常复核"); + return; + } + + setIsPending(true); + setMessage(""); + const response = await fetch(`/api/health/reviews/${reviewTarget.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ status: reviewStatus, resolutionNotes: reviewNotes }), + }); + const result = (await response.json()) as ApiResult<{ review: HealthAnomalyReview }>; + setIsPending(false); + setMessage(result.reason); + + if (result.success) { + closeReviewDialog(); + await refreshData(); + } + } + + return ( +
+
+ + + + +
+ +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {activeTab === "vitals" ? ( + setConditionStatusFilter(value as "all" | ChronicConditionStatus)} + options={[ + { value: "all", label: "全部状态" }, + ...Object.entries(CHRONIC_CONDITION_STATUS_LABELS).map(([value, label]) => ({ value, label })), + ]} + value={conditionStatusFilter} + /> + ) : null} + {activeTab === "reviews" ? ( + <> + setReviewSeverityFilter(value as "all" | HealthReviewSeverity)} + options={[ + { value: "all", label: "全部级别" }, + ...Object.entries(HEALTH_REVIEW_SEVERITY_LABELS).map(([value, label]) => ({ value, label })), + ]} + value={reviewSeverityFilter} + /> + + ) : null} + {activeTab === "vitals" ? ( + + ) : null} + {activeTab === "conditions" ? ( + + ) : null} +
+
+ + {message ? ( +

+ {message} +

+ ) : null} + + {activeTab === "profiles" ? ( + + + 健康档案 + 按老人维护过敏、病史、用药和紧急说明。 + + + + + + 老人 + 过敏 + 用药 + 紧急说明 + 操作 + + + + {filteredElders.map((elder) => { + const profile = profilesByElderId.get(elder.id); + return ( + + {elder.name} + {profile?.allergyNotes || "-"} + {profile?.medicationNotes || "-"} + {profile?.emergencyNotes || "-"} + + + + + ); + })} + +
+
+
+ ) : null} + + {activeTab === "vitals" ? ( + [ + vital.elderName, + formatDateTime(vital.recordedAt), + VITAL_SOURCE_LABELS[vital.source], + vital.systolicBp && vital.diastolicBp ? `${vital.systolicBp}/${vital.diastolicBp}` : "-", + vital.heartRate?.toString() ?? "-", + formatTenths(vital.temperatureTenths, "°C"), + vital.spo2 === undefined ? "-" : `${vital.spo2}%`, + vital.notes || "-", + ])} + title="生命体征" + /> + ) : null} + + {activeTab === "conditions" ? ( + [ + condition.elderName, + condition.name, + CHRONIC_CONDITION_STATUS_LABELS[condition.status], + formatDateTime(condition.diagnosedAt), + condition.treatmentNotes || "-", + condition.followUpNotes || "-", + ])} + title="慢病记录" + /> + ) : null} + + {activeTab === "reviews" ? ( + + + 异常复核 + 处理生命体征触发或手工创建的健康异常。 + + + + + + 老人 + 异常 + 级别 + 状态 + 处理人 + 操作 + + + + {filteredReviews.map((review) => ( + + {review.elderName} + +

{review.title}

+

{review.description}

+
+ + + {HEALTH_REVIEW_SEVERITY_LABELS[review.severity]} + + + + + {HEALTH_REVIEW_STATUS_LABELS[review.status]} + + + {review.reviewedByName ?? "-"} + + + +
+ ))} + {filteredReviews.length === 0 ? ( + + + 暂无异常复核 + + + ) : null} +
+
+
+
+ ) : null} + + 0} + title={editingProfile ? "编辑健康档案" : "新增健康档案"} + > +
+