Files
teatea-pension/.trellis/spec/backend/drizzle-postgres-current.md

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 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<AppData>
  • recordAuditLog(input: AuditInput): Promise<AuditLog>
  • requirePermission(permission: Permission, auditContext: DeniedAuditContext): Promise<PermissionCheckSuccess | PermissionCheckFailure>
  • 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:
type ApiResult<T extends Record<string, unknown>> =
  | ({ 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: "<entity>不存在" }.
  • 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

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.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

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/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<HealthAdminData>
  • 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

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, or family:read.
  • Mutations require module manage permission: device:manage, notice:manage, alert:manage, or family:manage.
  • All records must be scoped by active organizationId; updates/deletes must filter by both id and organizationId.
  • Read APIs return { success: true, reason, data } where data is the module workspace DTO.
  • Mutation APIs return the changed resource under a resource-specific key and write an audit log after successful persistence.
  • Seed demo rows only inside seedDefaultWorkspaceData(organizationId) and connect them to real seeded elders/devices/rules where applicable.

4. Validation & Error Matrix

  • Missing session or permission -> response from requirePermission; domain helper must not be called.
  • Missing active organization -> 400 with a Chinese reason asking the user to select an organization.
  • Invalid enum, empty required title/name/content, invalid date -> 400.
  • Cross-organization or missing referenced record -> 404 with entity-specific reason.
  • Insert/update returning no row -> structured mutation failure with status 500 or 404.

5. Good/Base/Bad Cases

  • Good: Server page checks read permission, loads persisted workspace data, and passes serializable DTOs to a Client Component.
  • Good: Client refreshes by calling the module read API after mutations.
  • Good: notice read receipts use POST /api/notices/[id] with read permission; create/update/delete use manage permission.
  • Base: alert rules and triggers are manually managed in v1; no background rule engine is implied.
  • Bad: rendering fake collaboration counters or rows on reserved pages after these modules have real tables.
  • Bad: updating a record by id alone without the active organization filter.

6. Tests Required

  • pnpm db:generate, then review SQL for additive collaboration enums/tables/indexes/FKs.
  • pnpm lint, pnpm type-check, pnpm test, and pnpm build.
  • API route tests should cover permission denial, missing active organization, successful create/update audit, and missing/cross-organization mutation failures.

7. Wrong vs Correct

Wrong

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/tasks
  • PATCH /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/care and /app/{organizationSlug}/care render the real care workspace once care_tasks exists.
  • Reads require care:read; mutations require care:manage.
  • GET /api/care/tasks returns { success: true; reason: string; data: CareExecutionData }; client refresh code must read result.data.
  • PATCH /api/care/tasks/[id] request: { status: "pending" | "in_progress" | "completed" | "cancelled"; executionNotes?: string }.
  • Completing a task sets completedAt and completedByAccountId; moving a task away from completed clears completion metadata.
  • Mutations update by both id and active organizationId, 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 -> 403 from requirePermission; 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_tasks table for MVP execution records instead of building a full recurring care-plan engine.
  • Good: preserve /app/health separation; care execution is operational and not the health admin settings page.
  • Base: elderId can be nullable for future public-area checks, but seeded MVP examples should use real elders.
  • Bad: render fake care metrics in ModulePage after the module becomes real.
  • Bad: 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 test with 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:generate after schema changes and review generated SQL for only care enum/table/index/FK changes.
  • pnpm lint, pnpm type-check, and pnpm 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)));