# Drizzle PostgreSQL Current Persistence Contract ## Scenario: Current Project After PostgreSQL Migration ### 1. Scope / Trigger - Trigger: implementing or modifying auth, RBAC, audit, elder, facility, admission, organization, settings, or dashboard data flows in this repository. - Applies because this project already has `drizzle-orm`, `postgres`, Drizzle migrations under `drizzle/`, and schema definitions under `modules/core/server/schema.ts`. - This supersedes local JSON persistence for current feature work. Local JSON guidance is only historical or for projects that have not adopted Drizzle yet. ### 2. Signatures - `getDatabase(): AppDatabase` - `checkDatabaseConnection(): Promise<{ ok: boolean; reason: string }>` - `readData(): Promise` - `recordAuditLog(input: AuditInput): Promise` - `requirePermission(permission: Permission, auditContext: DeniedAuditContext): Promise` - Route Handlers use standard `GET`, `POST`, `PATCH`, and `DELETE` exports and return `Response`. ### 3. Contracts - Environment key: `DATABASE_URL` is required for database-backed runtime behavior. - Drizzle source of truth: `modules/core/server/schema.ts`. - Migration output: `drizzle/`. - Database config: `drizzle.config.ts`. - Session cookie: - Name: `teatea_session` - Flags: `httpOnly`, `sameSite: "lax"`, `path: "/"` - Lifetime: 7 days - API response shape: ```ts type ApiResult> = | ({ success: true; reason: string } & T) | { success: false; reason: string }; ``` - `modules/core/server/store.ts` is a compatibility read model: - `readData()` may aggregate Drizzle rows for existing pages. - `writeData()` and `updateData()` must remain unavailable after migration. - New mutation code must use Drizzle queries or transactions. ### 4. Validation & Error Matrix - Missing `DATABASE_URL` -> throw during `getDatabase()` and surface a clear database configuration failure. - Missing/expired session -> `401` with `{ success: false, reason: "未登录或会话已过期" }`. - Missing permission -> `403` with `{ success: false, reason: "权限不足" }` and a denied audit log. - Invalid JSON body -> `400` with a Chinese user-facing `reason`. - Missing record by ID -> `404` with `{ success: false, reason: "不存在" }`. - Duplicate account email -> `409` with `{ success: false, reason: "账号已存在" }`. - Bed/admission conflict -> structured failure response and no partial mutation. ### 5. Good/Base/Bad Cases - Good: UI submits to a Route Handler, the handler validates input, calls `requirePermission`, mutates Drizzle tables in a transaction when multiple tables are involved, records audit, and returns `ApiResult`. - Base: Server Component reads data directly through focused server helpers or the temporary `readData()` compatibility model. - Bad: New mutation code calls `writeData()` or `updateData()`. - Bad: New code treats `.data/teatea.json` as the active persistence layer. - Bad: API auth is enforced only by hidden UI controls. ### 6. Tests Required - `pnpm lint` - `pnpm type-check` - `pnpm build` - When schema changes are made: `pnpm db:generate`, then review generated SQL and run `pnpm db:migrate` against the configured development database. - Manual or automated integration assertions: - first setup creates platform admin, organization, organization roles, membership, and cookie session - protected app route redirects without cookie - protected app route renders with a valid cookie - CRUD mutation persists across reload/API list - denied permission returns `403` and writes an audit log - admission mutation updates `admissions`, `beds`, and `elders` consistently ### 7. Wrong vs Correct #### Wrong ```ts await updateData((data) => { data.beds.push(newBed); }); ``` #### Correct ```ts const database = getDatabase(); await database.insert(beds).values({ organizationId, roomId, code, status: "available", }); ``` #### Wrong ```ts await database.insert(admissions).values({ organizationId, elderId, bedId }); await database.update(beds).set({ status: "occupied" }).where(eq(beds.id, bedId)); ``` #### Correct ```ts await database.transaction(async (transaction) => { await transaction.insert(admissions).values({ organizationId, elderId, bedId }); await transaction.update(beds).set({ status: "occupied" }).where(eq(beds.id, bedId)); await transaction.update(elders).set({ status: "active" }).where(eq(elders.id, elderId)); }); ``` ## Scenario: Facility Room Creation Without Fabricated Hierarchy ### 1. Scope / Trigger - Trigger: changing facility room creation APIs or any UI that creates rooms. - Applies because `rooms.floorId` is required by schema, but the system must not create fake campus/building/floor records to satisfy that foreign key. ### 2. Signatures - `POST /api/facilities/rooms` - Request: `{ name: string; code: string; floorId: string; capacity?: number }` - Response: `ApiResult<{ room: typeof rooms.$inferSelect }>` ### 3. Contracts - `floorId` must refer to an existing `floors.id` in the authenticated active organization. - `capacity` is optional; omitted means schema/product default `1`. - If `capacity` is supplied, it must be a positive integer. - The handler may insert only the `rooms` row. It must not insert `campuses`, `buildings`, or `floors` as placeholder hierarchy. ### 4. Validation & Error Matrix - Missing active organization -> `400` / `请选择机构后维护房间`. - Missing `name`, `code`, or `floorId` -> `400` / `房间名称、编号和楼层不能为空`. - Invalid supplied `capacity` -> `400` / `房间容量需为正整数`. - `floorId` not found in active organization -> `404` / `楼层不存在`. - Insert returning no row -> `500` / `房间创建失败`. ### 5. Good/Base/Bad Cases - Good: create UI first exposes real campus/building/floor selection, then submits the selected `floorId`. - Base: if facility hierarchy management is not exposed yet, keep room creation UI hidden or disabled. - Bad: auto-create `"默认院区"`, `"默认楼栋"`, or `"默认楼层"` inside the room API. ### 6. Tests Required - `pnpm lint` - `pnpm type-check` - `pnpm build` - Integration assertions: - missing `floorId` returns `400` - unknown or cross-organization `floorId` returns `404` - valid `floorId` creates a room without inserting campus/building/floor rows ### 7. Wrong vs Correct #### Wrong ```ts const floor = existingFloor ?? await transaction.insert(floors).values({ name: "默认楼层" }).returning(); await transaction.insert(rooms).values({ floorId: floor.id, name, code }); ``` #### Correct ```ts const floorRows = await database .select({ id: floors.id }) .from(floors) .where(and(eq(floors.id, floorId), eq(floors.organizationId, organizationId))) .limit(1); const floor = floorRows[0]; if (!floor) { return jsonFailure("楼层不存在", 404); } 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))); ```