689 行
28 KiB
Markdown
689 行
28 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: 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
|
|
|
|
```sql
|
|
INSERT INTO audit_logs (request_id, actor_id, extra)
|
|
VALUES ($1, $2::uuid, $3::jsonb);
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```sql
|
|
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
|
|
|
|
```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);
|
|
```
|
|
|
|
## 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
|
|
|
|
```js
|
|
summary.successCount = successesInThisRetry;
|
|
batch.status = failuresInThisRetry ? "failed" : "executed";
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
|
|
batch.execution_summary_json = finalSummary;
|
|
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed";
|
|
```
|
|
|
|
## Scenario: PostgreSQL Advisory-Lock Namespaces
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: allocating an application-level sequence or serializing a small
|
|
cross-row critical section that cannot be protected by locking one existing
|
|
row.
|
|
- Use this only for PostgreSQL-backed paths whose correctness is also verified
|
|
against a disposable real database; do not introduce advisory locks for
|
|
ordinary single-row updates already covered by `FOR UPDATE`.
|
|
|
|
### 2. Signatures
|
|
|
|
- Shared registry: `server/src/constants/advisoryLocks.js`.
|
|
- Lock shape: `pg_advisory_xact_lock(namespace_int4, object_key_int4)`.
|
|
- Registered namespace: `paymentBatchDailySequence = 1001`; its object key is
|
|
the company-local `YYYYMMDD` integer.
|
|
- Prisma call for a void-returning lock statement: `tx.$executeRaw`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Register every new first-argument namespace in the shared constant file;
|
|
never reuse an existing namespace for an unrelated critical section.
|
|
- Use the two-`int4` form with an explicit, inspectable object key. Do not use
|
|
`hashtext` or an undocumented magic pair at the call site.
|
|
- Acquire the transaction-level lock before reading the protected maximum or
|
|
current state. Lock acquisition, protected read and write must stay in the
|
|
same Prisma interactive transaction so commit or rollback releases the lock.
|
|
- Keep one stable lock order when a transaction needs more than one lock.
|
|
- A PostgreSQL uniqueness error aborts the transaction. Do not catch `P2002`
|
|
and retry inside the same interactive transaction; any fallback must retry
|
|
the complete transaction from the beginning.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- `$queryRaw` reads `SELECT pg_advisory_xact_lock(...)` -> Prisma driver may
|
|
fail with `P2010` because PostgreSQL returns a `void` column.
|
|
- `$executeRaw` executes the same `SELECT` -> lock is acquired without result
|
|
deserialization; continue to the protected query.
|
|
- Namespace or object key outside signed `int4` -> fail before business writes;
|
|
correct the registry/key design rather than silently casting or hashing.
|
|
- Protected daily sequence exhausted -> stable `409 VALID_001` and rollback;
|
|
do not widen or randomize the public identifier format at the boundary.
|
|
- Concurrent callers with the same pair -> one waits, then reads the committed
|
|
maximum and allocates the next value.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: two simultaneous payment-batch creations lock `(1001, YYYYMMDD)`, then
|
|
commit distinct consecutive identifiers.
|
|
- Base: different company-local dates use different object keys and do not
|
|
contend.
|
|
- Bad: query the maximum before taking the lock, use a process mutex, or retry a
|
|
unique violation inside the already-aborted transaction.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Unit test the exact call order and error boundary with mocked
|
|
`$executeRaw`/`$queryRaw`; assert no create, item or audit write on exhaustion.
|
|
- Run `bash scripts/qa/run-web-gates.sh payment-concurrency-local` against a
|
|
disposable PostgreSQL database. The daily-sequence race must create two
|
|
different sources concurrently and assert two consecutive unique numbers.
|
|
- Run `node --check` on the route, namespace registry and concurrency script,
|
|
plus Prisma validate and a read-only review for affected payment paths.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${namespace}, ${dateKey})`;
|
|
const latest = await readLatestSequence(tx);
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
await tx.$executeRaw`
|
|
SELECT pg_advisory_xact_lock(${namespace}::int, ${dateKey}::int)
|
|
`;
|
|
const latest = await readLatestSequence(tx);
|
|
```
|
|
|
|
## Scenario: Terminal Contract State and Required Audit
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: adding a terminal state or irreversible action to a Prisma-backed
|
|
business ledger, especially when the state needs explanatory fields and an
|
|
audit trail.
|
|
- Applies to `ContractRegistry` termination, archive corrections, all import
|
|
or batch writers, and any future endpoint that can mutate the same row.
|
|
|
|
### 2. Signatures
|
|
|
|
- `POST /api/contracts-registry/:id/terminate` with `{ reason }` where the
|
|
trimmed reason is 10–1000 characters.
|
|
- `POST /api/contracts-registry/:id/corrections` with `{ summary }` for an
|
|
archived row only.
|
|
- DB fields: `status`, `terminated_at`, `terminated_reason`,
|
|
`terminated_by`; the employee foreign key uses `ON DELETE RESTRICT`.
|
|
- Required audit helper: `writeRequiredAudit(tx, request, action, targetId,
|
|
extra)`; it must throw on insert failure.
|
|
|
|
### 3. Contracts
|
|
|
|
- `status = terminated` iff all three `terminated_*` fields are non-null and
|
|
non-empty; every other persisted status iff all three are null.
|
|
- Only the dedicated termination transaction may create `terminated`; general
|
|
POST/PATCH and CSV/import writers reject it. Existing terminated rows are
|
|
immutable to import updates, and existing archived rows are immutable to
|
|
import updates (use the correction API).
|
|
- Termination, status changes, renewal and correction audit writes stay inside
|
|
the same transaction as the protected write. A correction claims the current
|
|
`status + updated_at` version before appending its audit record.
|
|
- List DTOs use a minimal mapper; terminal reason, operator and timestamp are
|
|
detail-only fields.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Partial termination fields or a terminal row with missing fields -> stable
|
|
`500 SYS_001`; do not serialize an inconsistent record.
|
|
- General write with `terminated`, terminal edit/renew/archive, or a stale
|
|
correction version -> `409 VALID_001` and zero business/audit side effects.
|
|
- Required audit insert failure -> transaction rollback and `5xx`; never use a
|
|
swallowing best-effort helper for an irreversible/status-changing operation.
|
|
- Import status `terminated`/`expired`, a status transition, or update of an
|
|
existing terminated/archived row -> recorded validation error; no upsert.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: termination writes status, three fields and audit atomically; a failed
|
|
audit leaves the source row active and the audit table unchanged.
|
|
- Base: an archived correction updates only the version timestamp and appends
|
|
a safe audit summary; a concurrent restore loses the optimistic guard and
|
|
returns `409`.
|
|
- Bad: write status first, call a best-effort audit afterward, or let a CSV
|
|
upsert set `terminated` without the three fields and dedicated audit.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Focused route tests for complete/partial invariants, terminal filters and
|
|
effective status, general-write rejection, required-audit rollback,
|
|
renewal rollback, correction version conflicts, and safe list/detail DTOs.
|
|
- Unit test every non-HTTP import status guard and existing-terminal refusal.
|
|
- Prisma validate, migration status, `node --check` on touched scripts/routes,
|
|
focused Vitest, and Playwright coverage for desktop/mobile action boundaries.
|
|
- Read-only reviewer must inspect all write paths, including scripts and
|
|
exports, after implementation.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
await prisma.contractRegistry.update({ where: { id }, data: { status: "terminated" } });
|
|
await writeAudit(prisma, request, "CONTRACT_REGISTRY_TERMINATED", id); // swallows errors
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
await prisma.$transaction(async (tx) => {
|
|
const current = await readAndValidateContract(tx, id);
|
|
await tx.contractRegistry.updateMany({
|
|
where: { id, status: current.status, updated_at: current.updated_at },
|
|
data: buildTerminalFields(request, reason),
|
|
});
|
|
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
|
|
});
|
|
```
|