20 KiB
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 --checkon 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.jscd server && node --check src/routes/workflow.jscd server && ./scripts/regression-workflow-actions.shcd server && ./scripts/regression-all.sh
Scenario: Prisma Client Defaults In Raw SQL
1. Scope / Trigger
- Trigger: a script or route uses
$queryRaw,pgor handwrittenINSERTSQL 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 NULLwithout a databaseDEFAULT. - Raw insert must include
idand 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 omitid.
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 ashyb_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()beforebuildApp()must loaddotenv/configbefore reading config.
3. Contracts
migrate statusis safe and read-only; it may be run before asking the user.migrate deploywrites database schema state and requires explicit user confirmation unless the current task already approved that exact local DB.- Do not use
prisma migrate resetfor E2E recovery without separate backup and destructive-action approval. - Attached local E2E should disable background jobs with
JOBS_ENABLED=falseunless the test explicitly validates job behavior.
4. Validation & Error Matrix
migrate statusreports pending migrations -> record the migration names, inspect whether they are additive/destructive, then ask before applying.- Missing
JWT_SECRETduringnpm run devwhile.envexists -> verify the entrypoint loadsdotenv/configbeforegetConfig(). - 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 resetor manually add columns to unblock a test. - Bad: start the server with an ad hoc
node -r dotenv/configworkaround and leavenpm run devbroken.
6. Tests Required
cd server && npm run prisma:validate.bash scripts/qa/check-local-migration-drift.shwhen local DB drift was the cause.cd server && node --check src/index.jsif server bootstrap changed.- Focused Playwright flow that previously failed.
- Frontend
npm run buildwhen 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_v1from 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:
PortalAnnouncementmay carrycategory,requires_acknowledgement,acknowledgement_statement,acknowledgement_due_at. - DB:
PortalAnnouncementAcknowledgementstores one receipt per(announcement_id, employee_id)with statement/title/summary snapshots andactor_id. - API:
GET /api/announcements?manage=0returns visible announcements and the current employee's acknowledgement status. - API:
GET /api/announcements?manage=1requiressystem.settingsand returns acknowledgement summary plus minimal pending employees. - API:
POST /api/announcements/:id/acknowledgeaccepts{ "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, andstatus; 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_v1disabled ->409 VALID_001.- policy acknowledgement without statement ->
400 VALID_001. manage=1withoutsystem.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 theseed:devnpm 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_URLparsed with the samepgsemantics used by Prisma's PostgreSQL adapter.
3. Contracts
NODE_ENV=productionis 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_invalidordatabase_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.internalbecause onlyURL.hostnamewas 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 ESMvi.mockintercepting a CJSrequire. - 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.envor 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.envdatabase is reachable.
6. Tests Required
- Run health and static tests with
DATABASE_URL='' JWT_SECRET=''. - Run the full
scripts/check-minimal.shunder 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_codesandemail_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
!challengeafter 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/cancelwith{ 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|ceoreviewer; 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_v1is 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|faileditems. Existingexecuteditems 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_001and 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
executedwith 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";