文件
hyb-oa/.trellis/spec/hyb-oa/backend-fastify-prisma.md
T

386 行
15 KiB
Markdown

# Backend Fastify / Prisma
## Scope
Server work lives under `server/`. Prefer existing route, service, utility and
QA script patterns over new abstractions.
## Rules
- Validate Prisma changes with `cd server && npm run prisma:validate`.
- For route/service edits, run `node --check` on the touched files and the
focused regression listed in the current Trellis task.
- Prisma migrations require rollback notes in the Trellis task design or PRD.
- Do not resolve migration drift, reset databases, or rewrite migration history
without explicit approval.
- Workflow, approval and permission behavior must be verified at process level,
not only syntax level.
## Common Checks
- `cd server && node --check src/routes/reimbursements.js`
- `cd server && node --check src/routes/workflow.js`
- `cd server && ./scripts/regression-workflow-actions.sh`
- `cd server && ./scripts/regression-all.sh`
## Scenario: Local Regression Migration Drift
### 1. Scope / Trigger
- Trigger: a local or attached E2E fails with Prisma `missing column`,
`column does not exist`, enum missing, or route 500 immediately after a
schema-backed feature was added.
- Applies to `server/prisma/schema.prisma`, `server/prisma/migrations/`,
local regression databases such as `hyb_oa_regression`, and Playwright tests
that use real API/database flows.
### 2. Signatures
- Read-only drift check: `cd server && npx prisma migrate status`.
- Apply committed migrations after explicit confirmation:
`cd server && npx prisma migrate deploy`.
- Local service smoke for attached E2E:
`HOST=127.0.0.1 PORT=3000 JOBS_ENABLED=false SERVE_CLIENT_DIST=true npm run dev`.
- Server entrypoints that call `getConfig()` before `buildApp()` must load
`dotenv/config` before reading config.
### 3. Contracts
- `migrate status` is safe and read-only; it may be run before asking the user.
- `migrate deploy` writes database schema state and requires explicit user
confirmation unless the current task already approved that exact local DB.
- Do not use `prisma migrate reset` for E2E recovery without separate backup
and destructive-action approval.
- Attached local E2E should disable background jobs with `JOBS_ENABLED=false`
unless the test explicitly validates job behavior.
### 4. Validation & Error Matrix
- `migrate status` reports pending migrations -> record the migration names,
inspect whether they are additive/destructive, then ask before applying.
- Missing `JWT_SECRET` during `npm run dev` while `.env` exists -> verify the
entrypoint loads `dotenv/config` before `getConfig()`.
- Missing column after `migrate deploy` -> inspect actual columns and migration
history; do not patch tests to hide schema drift.
- E2E selector failure after schema is fixed -> treat as page/test contract
drift and update the focused E2E assertion, not the database.
### 5. Good/Base/Bad Cases
- Good: run `migrate status`, apply only committed migrations after approval,
seed dev users, then rerun the focused Playwright flow.
- Base: if migrations are already current, skip DB writes and debug the test
failure directly.
- Bad: use `migrate reset` or manually add columns to unblock a test.
- Bad: start the server with an ad hoc `node -r dotenv/config` workaround and
leave `npm run dev` broken.
### 6. Tests Required
- `cd server && npm run prisma:validate`.
- `bash scripts/qa/check-local-migration-drift.sh` when local DB drift was the
cause.
- `cd server && node --check src/index.js` if server bootstrap changed.
- Focused Playwright flow that previously failed.
- Frontend `npm run build` when Playwright or client behavior changed.
### 7. Wrong vs Correct
#### Wrong
```bash
cd server && npx prisma migrate reset
```
#### Correct
```bash
cd server && npx prisma migrate status
# after explicit approval for the local regression database only
cd server && npx prisma migrate deploy
```
## Scenario: Portal Announcement Acknowledgement
### 1. Scope / Trigger
- Trigger: extending `announcements_v1` from read-only announcements into formal employee acknowledgement.
- Applies to `server/prisma/schema.prisma`, `server/src/routes/announcements.js`, `client/src/api/announcements.ts`, employee `/announcements`, and admin `/system/announcements`.
- Use this only for lightweight policy acknowledgement; do not treat it as electronic signature, CA signing, contract signing, or full policy CMS.
### 2. Signatures
- DB: `PortalAnnouncement` may carry `category`, `requires_acknowledgement`, `acknowledgement_statement`, `acknowledgement_due_at`.
- DB: `PortalAnnouncementAcknowledgement` stores one receipt per `(announcement_id, employee_id)` with statement/title/summary snapshots and `actor_id`.
- API: `GET /api/announcements?manage=0` returns visible announcements and the current employee's acknowledgement status.
- API: `GET /api/announcements?manage=1` requires `system.settings` and returns acknowledgement summary plus minimal pending employees.
- API: `POST /api/announcements/:id/acknowledge` accepts `{ "confirmed": true }`.
### 3. Contracts
- Required acknowledgement status values: `not_required`, `pending`, `acknowledged`, `overdue`.
- Employee response must not include other employees' acknowledgement rows or pending lists.
- Admin pending list may include only `id`, `employeeNo`, `name`, and `status`; do not include phone, email, WeCom userid, account identifiers, or sensitive profile fields.
- Acknowledgement is explicit only: opening an announcement or clicking its link must never create a receipt.
- Policy content changes after any acknowledgement require a new announcement version; do not mutate title, summary, category, required flag, or statement in place.
### 4. Validation & Error Matrix
- `announcements_v1` disabled -> `409 VALID_001`.
- policy acknowledgement without statement -> `400 VALID_001`.
- `manage=1` without `system.settings` -> `403 PERM_001`.
- acknowledge invisible/missing announcement -> `404 ANN_404`.
- acknowledge normal announcement -> `409 VALID_001`.
- acknowledge without `{ confirmed: true }` -> `400 VALID_001`.
- edit protected policy fields after acknowledgement exists -> `409 VALID_001`.
### 5. Good/Base/Bad Cases
- Good: admin creates a policy announcement, employee checks the confirmation box, server creates one receipt and one audit log.
- Base: normal announcement remains compatible and returns `not_required`.
- Bad: frontend displays another employee's receipt status on `/announcements`.
- Bad: admin updates an acknowledged policy title instead of creating a new version.
### 6. Tests Required
- Prisma validate after schema/migration changes.
- Route focused tests for required statement, permission, idempotency, normal/invisible announcement rejection, admin summary, and protected edit rejection.
- Frontend typecheck and build.
- Playwright flow for admin create -> employee acknowledge -> admin summary update when UI changes.
### 7. Wrong vs Correct
#### Wrong
```js
await prisma.portalAnnouncement.update({
where: { id },
data: { title: "制度 V2" },
});
```
#### Correct
```js
const acknowledgementCount = await prisma.portalAnnouncementAcknowledgement.count({
where: { announcement_id: id },
});
if (acknowledgementCount > 0) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409 });
}
```
## Scenario: Safe Development Seed
### 1. Scope / Trigger
- Trigger: changing or running `server/src/scripts/seed-dev.js`, its safety
guard, development account defaults or the `seed:dev` npm command.
### 2. Signatures
- Command: `SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM=<db> npm run seed:dev`.
- Optional reset: `SEED_DEV_RESET_PASSWORDS=1`.
- Target: `DATABASE_URL` parsed with the same `pg` semantics used by Prisma's
PostgreSQL adapter.
### 3. Contracts
- `NODE_ENV=production` is permanently refused.
- The explicit allow switch and exact database-name confirmation are both
required.
- The effective PostgreSQL host, including query-string overrides, must be
loopback; checking only the URI authority is insufficient.
- Existing passwords are preserved by default. Newly created accounts and
explicitly rotated accounts set `must_change_password=true`.
- The guard and seed logs never print credentials, complete targets or account
passwords.
### 4. Validation & Error Matrix
- Production -> `production_forbidden`.
- Missing allow switch -> `explicit_allow_required`.
- Invalid/non-PostgreSQL URL -> `database_url_invalid` or
`database_protocol_invalid`.
- Effective non-loopback host, including `?host=...` ->
`database_host_not_local`.
- Missing/mismatched confirmation -> `database_confirmation_mismatch`.
### 5. Good/Base/Bad Cases
- Good: loopback test database, exact confirmation, new accounts require a
password change.
- Base: rerun without reset preserves existing password hashes.
- Bad: accept `postgresql://[email protected]/db?host=remote.internal` because only
`URL.hostname` was checked.
### 6. Tests Required
- Pure guard tests for production, missing allow, remote authority, host-query
override, exact confirmation and redacted errors.
- Unit tests for existing-password preservation, explicit rotation and new
account first-login change.
- Do not validate refusal paths by connecting to a real database.
### 7. Wrong vs Correct
#### Wrong
```js
if (new URL(env.DATABASE_URL).hostname === "127.0.0.1") allowSeed();
```
#### Correct
```js
const client = new Client({ connectionString: env.DATABASE_URL });
if (!LOOPBACK_HOSTS.has(client.host)) refuse("database_host_not_local");
```
## Scenario: Database-Free App Contract Tests
### 1. Scope / Trigger
- Trigger: unit tests call the production `buildApp()` factory to verify health,
readiness, static serving or other app-level contracts in CI without a DB.
### 2. Signatures
- Production: `buildApp()`.
- Test seam: `buildApp({ prismaPlugin, startJobs })`.
### 3. Contracts
- Omitted dependencies always use the production Prisma plugin and job starter.
- Unit tests inject a `fastify-plugin`-wrapped Prisma decorator and a no-op job
starter; they must not rely on ESM `vi.mock` intercepting a CJS `require`.
- App-level test doubles expose only methods used by the tested requests.
- `DATABASE_URL='' JWT_SECRET=''` must not make isolated app tests access a
developer `.env` or local PostgreSQL instance.
### 4. Validation & Error Matrix
- Production call with missing DB target -> normal startup refusal.
- Test call with injected Prisma plugin -> no production client construction.
- Test double missing a method exercised by the request -> focused unit failure.
- Injected dependencies leaking into `buildApp()` callers without options ->
contract regression.
### 5. Good/Base/Bad Cases
- Good: health/static tests inject Prisma and pass with empty DB/JWT process
variables after setting their own test JWT.
- Base: API/Worker call `buildApp()` without options and retain production wiring.
- Bad: a hoisted `vi.mock("../prismaClient")` appears green only because a local
`.env` database is reachable.
### 6. Tests Required
- Run health and static tests with `DATABASE_URL='' JWT_SECRET=''`.
- Run the full `scripts/check-minimal.sh` under the same empty environment.
- Keep the production default path covered by normal startup/smoke checks.
### 7. Wrong vs Correct
#### Wrong
```js
vi.mock("../../src/prismaClient", () => ({ createPrismaClient: fake }));
await buildApp(); // CJS plugin may still construct the real client
```
#### Correct
```js
await buildApp({ prismaPlugin: testPrismaPlugin, startJobs: noOpStartJobs });
```
## Scenario: Authentication Challenge Serialization
### 1. Scope / Trigger
- Trigger: changing password reset, email-code login, refresh rotation,
password login session creation, user password change or administrator reset.
- Applies when a credential event can consume a challenge, change
`token_version`, create/revoke a refresh session or invalidate another
challenge.
### 2. Signatures
- Row barrier: `SELECT ... FROM employees WHERE ... FOR UPDATE`.
- Challenge rows: `password_reset_codes` and `email_login_codes`.
- Session rows: `auth_sessions`.
- Security epoch: `employees.token_version`.
### 3. Contracts
- Use one lock order for credential mutations: Employee first, then challenge
and session rows. Refresh rotation must lock Employee and re-read the session
inside the same transaction before rotating it.
- Capture expiry, cooldown and lockout time after the Employee lock is acquired;
a request queued behind another credential event must not use stale time.
- Creating a new challenge invalidates the previous unused challenge while the
Employee lock is held. An active challenge with a wrong code increments the
account-level failure state; no active challenge returns the generic failure
without penalizing concurrent correct-request losers.
- Successful reset, user password change and administrator reset increment
`token_version`, revoke active refresh sessions and invalidate unused
challenges in the same transaction as the password change and audit event.
- Pepper-backed hashes use distinct purpose domains for email login and
password reset. Production rejects a missing, placeholder, weak or
JWT-reused pepper.
### 4. Validation & Error Matrix
- Unknown/inactive/locked account or missing/expired/consumed challenge ->
stable generic response; no account enumeration detail.
- Wrong code for the current active challenge -> increment account and
challenge attempts; reaching the bound locks and invalidates the challenge.
- Reset commits before login/refresh -> the queued request rechecks state and
cannot create a usable session.
- Login/refresh commits before reset -> reset observes and revokes the newly
created session before it commits.
- Concurrent consumers of one valid challenge -> exactly one successful state
transition; losers do not create audits, sessions or a recovery lock.
### 5. Good/Base/Bad Cases
- Good: every credential path uses Employee -> challenge/session ordering and
is covered by a deterministic PostgreSQL lock race.
- Base: sequential invalid-code attempts use the same account-level counters
and return the same external error contract.
- Bad: refresh locks a session before Employee while reset locks Employee
before sessions; this can leave a newly inserted session outside reset's
statement snapshot.
- Bad: treat `!challenge` after another request consumed the code as a new
failed guess and lock the legitimate account.
### 6. Tests Required
- Focused route tests for CSPRNG/HMAC domain separation, cooldown, account
lockout, missing-challenge losers, conditional consumption and session
revocation.
- Real isolated PostgreSQL checks for concurrent challenge requests, 20-way
same-code consumption, reset-first/login-first ordering and refresh/reset.
- After reset, assert the pre-reset access token and refresh cookie both fail,
the active-session count is zero and only one success audit exists.
- Prisma validate plus clean migration deployment from an empty database.
### 7. Wrong vs Correct
#### Wrong
```js
await tx.authSession.updateMany({ where: { id: sessionId, revoked_at: null } });
await tx.authSession.create({ data: replacement });
```
#### Correct
```js
await lockEmployeeForUpdate(tx, employeeId);
const currentSession = await tx.authSession.findUnique({ where: { id: sessionId } });
if (!currentSession || currentSession.revoked_at) return invalidRefresh();
await rotateSession(tx, currentSession);
```