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

20 KiB

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: Prisma Client Defaults In Raw SQL

1. Scope / Trigger

  • Trigger: a script or route uses $queryRaw, pg or handwritten INSERT SQL against a Prisma-managed table whose schema declares @default(uuid()) or another client-side default.

2. Signatures

  • Prisma signature: id String @id @default(uuid()) @db.Uuid.
  • Database signature may still be id UUID NOT NULL without a database DEFAULT.
  • Raw insert must include id and bind an explicit UUID, for example $1::uuid.

3. Contracts

  • Prisma schema defaults are not assumed to exist in PostgreSQL; inspect the applied migration or live column default before omitting a raw-SQL field.
  • Raw audit inserts generate a fresh UUID in application code and keep the audit insert in the same transaction as the protected write.
  • Dry-run may exercise audit inserts, but it must roll the transaction back and report zero committed audit rows.

4. Validation & Error Matrix

  • Raw insert omits a required client-default field with no DB default -> PostgreSQL not-null failure; transaction rolls back.
  • Explicit UUID has invalid syntax -> PostgreSQL cast failure; transaction rolls back.
  • Audit insert fails after the business update -> the complete transaction rolls back; no partial rule update is allowed.

5. Good/Base/Bad Cases

  • Good: explicit UUID, request ID, actor, target and JSON audit payload are bound and tested position by position.
  • Base: Prisma Client create() may rely on the Prisma default because the client supplies it.
  • Bad: infer from @default(uuid()) that handwritten PostgreSQL may omit id.

6. Tests Required

  • Assert the raw SQL column list contains the required ID and the bound value is a valid UUID.
  • Assert every parameter position maps to its intended DB type.
  • Assert dry-run begins a serializable transaction and executes ROLLBACK; audit failure paths must remain fail-closed.

7. Wrong vs Correct

Wrong

INSERT INTO audit_logs (request_id, actor_id, extra)
VALUES ($1, $2::uuid, $3::jsonb);

Correct

INSERT INTO audit_logs (id, request_id, actor_id, extra)
VALUES ($1::uuid, $2, $3::uuid, $4::jsonb);

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

cd server && npx prisma migrate reset

Correct

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

await prisma.portalAnnouncement.update({
  where: { id },
  data: { title: "制度 V2" },
});

Correct

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

if (new URL(env.DATABASE_URL).hostname === "127.0.0.1") allowSeed();

Correct

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

vi.mock("../../src/prismaClient", () => ({ createPrismaClient: fake }));
await buildApp(); // CJS plugin may still construct the real client

Correct

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

await tx.authSession.updateMany({ where: { id: sessionId, revoked_at: null } });
await tx.authSession.create({ data: replacement });

Correct

await lockEmployeeForUpdate(tx, employeeId);
const currentSession = await tx.authSession.findUnique({ where: { id: sessionId } });
if (!currentSession || currentSession.revoked_at) return invalidRefresh();
await rotateSession(tx, currentSession);

Scenario: Small-Team Payment Batch Consistency

1. Scope / Trigger

  • Trigger: changing payment-batch creation, review, execution, retry, cancellation, receipt evidence or the legacy direct-payment path.

2. Signatures

  • Candidate API: GET /api/payment-batches/candidates?q=&page=&pageSize=.
  • State APIs: POST /api/payment-batches/:id/review, POST /api/payment-batches/:id/execute, POST /api/payment-batches/:id/cancel with { reason }.
  • Receipt invalidation accepts { reason } and retains the record.
  • DB: payment_batches.cancelled_reason TEXT NULL.
  • Isolated concurrency gate: bash scripts/qa/run-web-gates.sh payment-concurrency-local.

3. Contracts

  • Normal flow is finance maker/executor plus one independent manager|ceo reviewer; the maker cannot review their own batch.
  • Lock the batch before state checks, then lock source entities in stable order and recheck status, amount and reservation inside the same transaction.
  • When payment_batch_v1 is enabled, an approved reimbursement can enter only one active batch and the legacy direct-payment route must not form a second write path.
  • Retry processes only pending|failed items. Existing executed items never repeat payment writes, follow-ups or notifications. Persisted execution summary is recomputed from every final item state; the audit separately records the current attempt.
  • Cancellation requires a 2-500 character reason. Receipt evidence is voided with audit history, not physically deleted.

4. Validation & Error Matrix

  • Missing review, maker-reviewer conflict or terminal state -> 409 VALID_001.
  • Concurrent loser after a valid winner -> deterministic 409, zero duplicate state, audit or notification writes.
  • Missing/invalid cancellation or void reason -> 400 VALID_001.
  • Source already paid, amount changed or reserved by another active batch -> 409 VALID_001 and no partial batch mutation.

5. Good/Base/Bad Cases

  • Good: one executor wins, a concurrent cancel or second executor receives 409, and the final payment/audit/notification counts remain one.
  • Base: a failed batch retries only failed items and reaches executed with an all-item final summary.
  • Bad: count only the retry attempt in execution_summary_json, or allow the direct route to pay while the same source is in a valid batch.

6. Tests Required

  • Focused route tests for candidate authority, maker-checker, retry summary, cancellation, receipt void and zero-side-effect refusals.
  • Real disposable PostgreSQL races for double execute, execute/cancel, double create and direct-payment/batch competition; assert unique audits and notifications and zero residual test databases.
  • Prisma validate, clean empty-database migration application and a read-only reviewer after payment-state changes.

7. Wrong vs Correct

Wrong

summary.successCount = successesInThisRetry;
batch.status = failuresInThisRetry ? "failed" : "executed";

Correct

const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
batch.execution_summary_json = finalSummary;
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed";