# 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:

```ts
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

```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 });
```
