17 KiB
17 KiB
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 underdrizzle/, and schema definitions undermodules/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(): AppDatabasecheckDatabaseConnection(): Promise<{ ok: boolean; reason: string }>readData(): Promise<AppData>recordAuditLog(input: AuditInput): Promise<AuditLog>requirePermission(permission: Permission, auditContext: DeniedAuditContext): Promise<PermissionCheckSuccess | PermissionCheckFailure>- Route Handlers use standard
GET,POST,PATCH, andDELETEexports and returnResponse.
3. Contracts
- Environment key:
DATABASE_URLis 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
- Name:
- API response shape:
type ApiResult<T extends Record<string, unknown>> =
| ({ success: true; reason: string } & T)
| { success: false; reason: string };
modules/core/server/store.tsis a compatibility read model:readData()may aggregate Drizzle rows for existing pages.writeData()andupdateData()must remain unavailable after migration.- New mutation code must use Drizzle queries or transactions.
4. Validation & Error Matrix
- Missing
DATABASE_URL-> throw duringgetDatabase()and surface a clear database configuration failure. - Missing/expired session ->
401with{ success: false, reason: "未登录或会话已过期" }. - Missing permission ->
403with{ success: false, reason: "权限不足" }and a denied audit log. - Invalid JSON body ->
400with a Chinese user-facingreason. - Missing record by ID ->
404with{ success: false, reason: "<entity>不存在" }. - Duplicate account email ->
409with{ 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 returnsApiResult. - Base: Server Component reads data directly through focused server helpers or the temporary
readData()compatibility model. - Bad: New mutation code calls
writeData()orupdateData(). - Bad: New code treats
.data/teatea.jsonas the active persistence layer. - Bad: API auth is enforced only by hidden UI controls.
6. Tests Required
pnpm lintpnpm type-checkpnpm build- When schema changes are made:
pnpm db:generate, then review generated SQL and runpnpm db:migrateagainst 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
403and writes an audit log - admission mutation updates
admissions,beds, andeldersconsistently
7. Wrong vs Correct
Wrong
await updateData((data) => {
data.beds.push(newBed);
});
Correct
const database = getDatabase();
await database.insert(beds).values({
organizationId,
roomId,
code,
status: "available",
});
Wrong
await database.insert(admissions).values({ organizationId, elderId, bedId });
await database.update(beds).set({ status: "occupied" }).where(eq(beds.id, bedId));
Correct
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.floorIdis 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
floorIdmust refer to an existingfloors.idin the authenticated active organization.capacityis optional; omitted means schema/product default1.- If
capacityis supplied, it must be a positive integer. - The handler may insert only the
roomsrow. It must not insertcampuses,buildings, orfloorsas placeholder hierarchy.
4. Validation & Error Matrix
- Missing active organization ->
400/请选择机构后维护房间. - Missing
name,code, orfloorId->400/房间名称、编号和楼层不能为空. - Invalid supplied
capacity->400/房间容量需为正整数. floorIdnot 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 lintpnpm type-checkpnpm build- Integration assertions:
- missing
floorIdreturns400 - unknown or cross-organization
floorIdreturns404 - valid
floorIdcreates a room without inserting campus/building/floor rows
- missing
7. Wrong vs Correct
Wrong
const floor = existingFloor ?? await transaction.insert(floors).values({ name: "默认楼层" }).returning();
await transaction.insert(rooms).values({ floorId: floor.id, name, code });
Correct
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/healthplaceholder.
2. Signatures
GET /api/health/adminPUT /api/health/profiles/[elderId]POST /api/health/vitalsPOST /api/health/chronic-conditionsPATCH /api/health/reviews/[id]listHealthAdminData(organizationId: string): Promise<HealthAdminData>- Mutations return either the domain DTO or
{ success: false; reason: string; status: number }.
3. Contracts
- Health APIs must call
requirePermissionbefore reading or mutating:- reads use
health:read - mutations use
health:manage
- reads use
- All health rows are scoped by
organizationId. - Elder-owned health mutations must validate the elder exists in the active organization.
GET /api/health/adminreturns{ success: true; reason: string; data: HealthAdminData }; client refresh code must readresult.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 }
- profile:
- Mutations must write audit logs after successful persistence.
4. Validation & Error Matrix
- Missing active organization ->
400with a Chinese reason instructing the user to select an organization. - Missing
elderIdfor 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 ->
403fromrequirePermission; route must not call domain helpers. - Insert/update returning no row ->
500mutation failure.
5. Good/Base/Bad Cases
- Good: keep
/app/settings/healthas the backend management surface and leave/app/healthoperational until real operational data exists. - Good: return the admin payload under a
datakey 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:managein the Route Handler. - Bad: update reviews by
idalone without also filtering by activeorganizationId.
6. Tests Required
pnpm testwith 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:generateafter schema changes, then review the generated SQL for only additive health enum/table/index/FK changes.pnpm lint,pnpm type-check, andpnpm build.
7. Wrong vs Correct
Wrong
const data = await listHealthAdminData(organizationId);
return jsonSuccess("健康数据已加载", data);
Correct
Scenario: Collaboration Module Data Management
1. Scope / Trigger
- Trigger: changing 设备运维, 公告通知, 规则预警, or 家属服务 pages, APIs, seed data, or schema.
- Applies because these modules are real Drizzle/PostgreSQL-backed collaboration workspaces, not reserved/static module pages.
2. Signatures
GET /api/devices/assets,POST /api/devices/assets,PATCH|DELETE /api/devices/assets/[id]POST /api/devices/tickets,PATCH|DELETE /api/devices/tickets/[id]GET|POST /api/notices,POST|PATCH|DELETE /api/notices/[id]GET|POST /api/alerts/rules,PATCH|DELETE /api/alerts/rules/[id]POST /api/alerts/triggers,PATCH|DELETE /api/alerts/triggers/[id]GET|POST /api/family/contacts,PATCH|DELETE /api/family/contacts/[id]POST /api/family/visits,PATCH|DELETE /api/family/visits/[id]POST /api/family/feedback,PATCH|DELETE /api/family/feedback/[id]
3. Contracts
- Reads require module read permission:
device:read,notice:read,alert:read, orfamily:read. - Mutations require module manage permission:
device:manage,notice:manage,alert:manage, orfamily:manage. - All records must be scoped by active
organizationId; updates/deletes must filter by bothidandorganizationId. - Read APIs return
{ success: true, reason, data }wheredatais the module workspace DTO. - Mutation APIs return the changed resource under a resource-specific key and write an audit log after successful persistence.
- Seed demo rows only inside
seedDefaultWorkspaceData(organizationId)and connect them to real seeded elders/devices/rules where applicable.
4. Validation & Error Matrix
- Missing session or permission -> response from
requirePermission; domain helper must not be called. - Missing active organization ->
400with a Chinese reason asking the user to select an organization. - Invalid enum, empty required title/name/content, invalid date ->
400. - Cross-organization or missing referenced record ->
404with entity-specific reason. - Insert/update returning no row -> structured mutation failure with status
500or404.
5. Good/Base/Bad Cases
- Good: Server page checks read permission, loads persisted workspace data, and passes serializable DTOs to a Client Component.
- Good: Client refreshes by calling the module read API after mutations.
- Good: notice read receipts use
POST /api/notices/[id]with read permission; create/update/delete use manage permission. - Base: alert rules and triggers are manually managed in v1; no background rule engine is implied.
- Bad: rendering fake collaboration counters or rows on reserved pages after these modules have real tables.
- Bad: updating a record by
idalone without the active organization filter.
6. Tests Required
pnpm db:generate, then review SQL for additive collaboration enums/tables/indexes/FKs.pnpm lint,pnpm type-check,pnpm test, andpnpm build.- API route tests should cover permission denial, missing active organization, successful create/update audit, and missing/cross-organization mutation failures.
7. Wrong vs Correct
Wrong
await database.update(alertTriggers).set({ status }).where(eq(alertTriggers.id, id));
Correct
await database
.update(alertTriggers)
.set({ status, updatedAt: new Date() })
.where(and(eq(alertTriggers.id, id), eq(alertTriggers.organizationId, organizationId)));
const data = await listHealthAdminData(organizationId);
return jsonSuccess("健康数据已加载", { data });
Wrong
await database.update(healthAnomalyReviews).set({ status }).where(eq(healthAnomalyReviews.id, id));
Correct
await database
.update(healthAnomalyReviews)
.set({ status, updatedAt: new Date() })
.where(and(eq(healthAnomalyReviews.id, id), eq(healthAnomalyReviews.organizationId, organizationId)));
Scenario: Care Execution Workspace
1. Scope / Trigger
- Trigger: adding or changing persisted daily care task execution behavior.
- Applies when replacing reserved operational module placeholders with real care task data. Care records must be Drizzle-backed and organization-scoped.
2. Signatures
GET /api/care/tasksPATCH /api/care/tasks/[id]listCareExecutionData(organizationId: string): Promise<CareExecutionData>updateCareTaskStatus(input): Promise<CareTask | { success: false; reason: string; status: number }>- Drizzle table:
care_tasks
3. Contracts
/app/careand/app/{organizationSlug}/carerender the real care workspace oncecare_tasksexists.- Reads require
care:read; mutations requirecare:manage. GET /api/care/tasksreturns{ success: true; reason: string; data: CareExecutionData }; client refresh code must readresult.data.PATCH /api/care/tasks/[id]request:{ status: "pending" | "in_progress" | "completed" | "cancelled"; executionNotes?: string }.- Completing a task sets
completedAtandcompletedByAccountId; moving a task away fromcompletedclears completion metadata. - Mutations update by both
idand activeorganizationId, then write an audit log after success.
4. Validation & Error Matrix
- Missing active organization on list ->
400/请选择机构后查看护理任务. - Missing active organization on mutation ->
400/请选择机构后处理护理任务. - Invalid status ->
400/护理任务状态无效. - Missing or cross-organization task ID ->
404/护理任务不存在. - Missing permission ->
403fromrequirePermission; route must not call domain helpers. - Update returning no row -> structured mutation failure.
5. Good/Base/Bad Cases
- Good: keep care task examples in default workspace seed tied to real seeded elders.
- Good: use one additive
care_taskstable for MVP execution records instead of building a full recurring care-plan engine. - Good: preserve
/app/healthseparation; care execution is operational and not the health admin settings page. - Base:
elderIdcan be nullable for future public-area checks, but seeded MVP examples should use real elders. - Bad: render fake care metrics in
ModulePageafter the module becomes real. - Bad: update task status by ID alone without
organizationId. - Bad: only disable buttons in the UI while leaving Route Handlers on broad elder permissions.
6. Tests Required
pnpm testwith API assertions for list and status update routes.- Route tests must cover happy path, permission denial, missing organization, invalid status, and missing/cross-organization task IDs.
pnpm db:generateafter schema changes and review generated SQL for only care enum/table/index/FK changes.pnpm lint,pnpm type-check, andpnpm build.
7. Wrong vs Correct
Wrong
await database.update(careTasks).set({ status }).where(eq(careTasks.id, id));
Correct
await database
.update(careTasks)
.set({ status, updatedAt: new Date() })
.where(and(eq(careTasks.id, id), eq(careTasks.organizationId, organizationId)));