Compare commits

..

8 Commits

91 changed files with 3417 additions and 136 deletions

View File

@ -12,3 +12,4 @@ RECOVERY_TTL_MINUTES=15
# SMTP_PASSWORD=replace_me
# SMTP_FROM=support@example.com
# RECOVERY_URL=https://shop.example.com/reset
DATABASE_POOL_SIZE=10

View File

@ -27,6 +27,7 @@ jobs:
node-version: '24'
- run: npm install --global pnpm@11.19.0
- run: pnpm install --frozen-lockfile
- run: pnpm security:audit
- run: pnpm db:generate
- run: node scripts/verify-migrations.mjs
- run: pnpm check

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ coverage/
!.env.example
src/generated/
*.log
.tmp/

View File

@ -1,6 +1,6 @@
# Mani Candles backend
Phase 1A provides the service foundation. Phase 1B adds staff account provisioning, authentication, sessions, recovery, configurable RBAC, and audit records. Commerce APIs follow in Phase 1C.
Phase 1A provides the service foundation. Phase 1B adds staff account provisioning, authentication, sessions, recovery, configurable RBAC, and audit records. Phase 1C implements core commerce APIs.
## Setup
@ -29,3 +29,4 @@ Development: run `pnpm dev` to compile on changes and `pnpm start:watch` in a se
Production must use a managed secret store, TLS termination, a restricted database account, backups, and the deployment migration command. Do not expose this foundation as a completed commerce platform.
Identity: see [API contract](docs/identity-api.md) and [setup/release guide](docs/identity-operations.md).
Phase 1C adds catalog, private addresses and inventory. See [commerce API](docs/commerce-api.md), [errors](docs/error-contract.md), [migrations](docs/migrations.md), [security preparation](docs/vapt-readiness.md) and [verification](docs/verification.md).

27
docs/commerce-api.md Normal file
View File

@ -0,0 +1,27 @@
# Phase 1C commerce API
All routes use /api/v1. Protected routes require a bearer session and current permissions. Organization and user scope come from the session. PUT replaces editable input fields. Module schemas define the field contracts.
| Routes | Permission |
| ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| GET /products, /products/:id, /catalog-groups | catalog.read |
| POST /products, PUT /products/:id, POST /products/:id/variants, PUT /products/:id/variants/:variantId | catalog.manage |
| POST /catalog-groups, PUT /catalog-groups/:id | catalog.manage |
| PATCH /products/:id/status | catalog.publish |
| GET /storefront/:organizationId/products and /:id | Public, throttled |
| GET/POST /addresses, PUT/DELETE /addresses/:id | Authenticated, own addresses |
| GET /inventory/warehouses, /inventory/stock-items, /inventory/stock-items/:id, /inventory/stock-items/:id/ledger | inventory.read |
| POST /inventory/warehouses, /inventory/stock-items | inventory.manage |
| POST /inventory/adjustments | inventory.adjust |
| POST /inventory/reservations, GET /inventory/reservations/:id, POST /inventory/reservations/:id/release | inventory.reserve; reads/releases belong to creator |
| POST /inventory/reservations/:id/commit | inventory.commit, same organization |
Products begin as drafts. Publishing requires an active variant; archived products cannot be edited. Groups are categories or collections. Public lists contain summaries; details expose published products and active variants. Price input is a positive decimal string with two fractional digits, stored as exact Decimal(12,2) with explicit currency. Output may omit trailing zeroes. Lists and nested inputs are bounded.
Addresses are private, limited to 20 per user and have one default enforced by a partial unique index. Deleting the default selects a replacement when possible. Private address values are excluded from audits.
Stock quantities are integers. Opening stock is an adjustment. Adjustments require stockItemId, nonzero delta, reason and UUID idempotencyKey. Reservations require stockItemId, positive quantity, UUID idempotencyKey and optional ttlMinutes (160, default 15). Matching retries return the original result; changed input conflicts.
Availability equals on-hand stock minus unexpired active reservations. Stock row locks serialize changes. Expired holds stop consuming availability and cannot be committed. Release and commit are idempotent. Commit decrements stock and appends one ledger entry in the same transaction. A database trigger rejects ledger updates/deletes.
Existing system roles receive permissions through migration; custom roles require explicit grants. This phase covers finished goods. Checkout/orders and pricing snapshots follow in Phase 1D; procurement and production in Phase 2.

View File

@ -9,3 +9,4 @@ Controllers translate HTTP; services own use cases; repositories own persistence
For schema changes, run `pnpm db:migrate --name descriptive_change` against a disposable development database. Review generated SQL and commit it with the feature. Never edit an applied migration or use `db push` in production. Use expand/backfill/contract for destructive changes; back up and test recovery before deployment. Apply `pnpm db:deploy` once in the release pipeline, then verify migration status and readiness. Test migrations from an empty database and the prior release snapshot.
Money uses exact decimal or minor units with explicit currency. Inventory uses ledger entries and transactions. Orders, payments and shipments have separate states. External writes need idempotency and authenticated webhooks. Private documents require authorization and isolated processing. These rules are implemented and tested within the corresponding milestones.
See [append-only migration workflow](migrations.md) for timestamp names and checksum recording.

9
docs/error-contract.md Normal file
View File

@ -0,0 +1,9 @@
# API errors and diagnostics
Errors return `{ statusCode, code, message, requestId }`; validation errors may include fields. Clients should branch on stable codes. Definitions live in src/common/errors/platform-errors.ts and commerce-errors.ts.
Each defined error has a distinct code and message. The global filter logs the event, diagnostic, server-generated request ID, method and route template. Unexpected faults include a fingerprint. Raw exceptions, SQL, credentials, request bodies and address values are not logged or returned.
Login failures deliberately share a public message to prevent enumeration; internal diagnostics distinguish causes. Unknown failures return INTERNAL_FAILURE with a safe message. Known database failures are classified centrally.
Responses include X-Request-Id and Cache-Control: no-store. Throttled responses include Retry-After seconds. Client request IDs are not trusted. Configure restricted log access, retention and alerting at deployment.

14
docs/migrations.md Normal file
View File

@ -0,0 +1,14 @@
# Append-only migrations
Never edit or rename a committed migration. Correct mistakes with the next migration. New directories use Prisma's UTC timestamp prefix: YYYYMMDDHHmmss_description.
1. Change the relevant schema file under prisma/.
2. Run `pnpm db:migrate --name descriptive_change` against a disposable development database and review the generated SQL.
3. Run `node scripts/check-migrations.mjs --record-new`. This verifies recorded checksums before adding new entries.
4. Run `pnpm db:generate`, `pnpm db:test` and `pnpm check`. Commit schema, SQL, checksums and tests together.
The check normalizes line endings and rejects modified or missing recorded migrations and invalid new timestamp names. Review manifest changes against the base branch; replacing an old checksum is not an acceptable repair.
Production uses `pnpm db:deploy`, then `pnpm db:status`. Never use db push in production. Schema diff tooling targets the whole prisma directory. Destructive changes need an expand/backfill/contract rollout and recovery planning.
Phase 1C appends four migrations after the original three: catalog/addresses, inventory, commerce integrity and inventory actor scope. SQL maintains additional integrity constraints and the append-only ledger trigger.

View File

@ -6,7 +6,7 @@ Source: Mani Candles Commerce Platform specification and project pack created in
- 1A (implemented): service bootstrap, configuration, database lifecycle, initial organization migration, health API, test/build baseline, team workflow.
- 1B (implemented; native CI and deployment configuration pending): authentication, sessions, recovery, users, configurable RBAC, organization access, approval status, audit events. Test denied access and cross-organization access.
- 1C: catalog, variants, collections, addresses, inventory ledger and reservations. Test concurrent reservation and stock reconciliation.
- 1C (implemented; native concurrency CI pending): catalog, variants, collections, addresses, inventory ledger and reservations. Test concurrent reservation and stock reconciliation.
- 1D: cart, checkout, orders, coupons and pricing snapshots. Test money precision, discount eligibility, retries and transaction rollback.
- 1E: verified payments/refunds, shipping/tracking, returns, notifications and operational dashboard. Test signatures, replay, partial fulfillment and reconciliation.

13
docs/vapt-readiness.md Normal file
View File

@ -0,0 +1,13 @@
# Security assessment preparation
This phase supplies controls and regression evidence for assessment; no formal VAPT or certification has been completed.
Controls include current session/permission checks, organization-scoped lookups and composite foreign keys, private address ownership, bounded schemas, parameterized queries, security headers, explicit CORS, 32 KB bodies, durable rate limits and redacted errors/logs. Stock mutations use transactions, row locks, idempotency and an immutable ledger. Migration checksums guard history.
Tests cover denied/cross-organization access, mass assignment, malformed and oversized JSON, query bounds, hostile text, error redaction, forged request IDs and inventory state transitions. Production dependency audit reports zero advisories at verification; CI rejects high/critical findings.
Before release, run native PostgreSQL concurrency tests and Gitea CI, validate TLS and trusted-proxy configuration, test rate limits at the actual network boundary, restrict database/log access, provision secrets and backups, verify restore procedures and commission authenticated and unauthenticated VAPT against the deployed environment.
Live SMTP and the recovery frontend remain pending. Recovery delivery is synchronous until the notification queue milestone; assess timing-based enumeration with the real adapter. No external email or production deployment was performed.
References: [OWASP ASVS](https://owasp.org/www-project-application-security-verification-standard/) and [Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html). Conformance has not been independently assessed.

View File

@ -1,16 +1,14 @@
# Verification record — Phase 1B
# Verification record — Phase 1C
Completed locally:
- 131 passing tests across 21 suites; two native PostgreSQL concurrency tests are skipped without TEST_DATABASE_URL.
- Coverage: 99.61% statements, 99.56% lines, 87.21% branches and 100% functions for measured application code.
- Formatting, migration checksums, Prisma validation, strict TypeScript checks and production compilation pass.
- All seven migrations execute through Prisma migrate deploy; status is up to date and schema diff reports no drift against the disposable embedded PostgreSQL engine. The original three migrations remain unchanged; four timestamped migrations were appended.
- Tests cover publication, private addresses, stock reconciliation, reservation expiry/retry/commit, append-only ledgers, organization boundaries and safe HTTP errors.
- Production dependency audit reports zero advisories after targeted transitive dependency overrides.
- 86 passing tests across 13 suites; one native PostgreSQL concurrency test is intentionally skipped without TEST_DATABASE_URL.
- 100% statements, lines and functions; 86.11% branches for hand-written application code. Generated code, Nest module declarations and CLI/HTTP entrypoint wrappers are excluded. Bootstrap business logic is tested.
- Formatting, Prisma schema validation, strict TypeScript/unused-code checks and production compilation pass.
- All three migrations execute through Prisma migrate deploy; migrate status reports up to date and schema diff reports no drift against a disposable embedded PostgreSQL engine.
- Migration tests preserve a pre-existing organization row, reject cross-organization role assignments, enforce normalized email and one owner, and prevent audit updates/deletes.
- API tests cover pending/suspended accounts, revoked/expired sessions, current permissions, privilege escalation, recovery replay, throttling and transaction rollback.
Native PostgreSQL concurrency and remote Gitea CI remain pending. CI provisions PostgreSQL 17 for competing reservations and recovery consumption. Embedded tests do not establish multi-connection locking behavior.
Native PostgreSQL concurrency and the remote Gitea workflow have not been verified locally. The workflow provisions PostgreSQL 17 and enables the native test path. A Docker-capable Gitea runner and Actions access are required.
No production deployment, real SMTP delivery or formal VAPT was performed. See [assessment preparation](vapt-readiness.md) for release checks.
Recovery email is tested using a mock SMTP adapter; no external email was sent. Live SMTP, a frontend recovery page and production deployment/owner bootstrap remain environment setup tasks. Recovery email is synchronous pending the later notification queue milestone.
Git identity: mihir <motiyanimihir@gmail.com>. Work is based on the fetched main branch and lives on feat/identity-access.
Git author: mihir <motiyanimihir@gmail.com>. Branch: feat/catalog-inventory, based on fetched main.

View File

@ -19,11 +19,13 @@
"db:validate": "prisma validate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
"check": "pnpm migrations:check && pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
"start:watch": "node --watch dist/main.js",
"db:status": "prisma migrate status",
"bootstrap:owner": "node dist/cli/bootstrap-owner.js",
"db:test": "node scripts/test-migrations.mjs"
"db:test": "node scripts/test-migrations.mjs",
"migrations:check": "node scripts/check-migrations.mjs",
"security:audit": "pnpm audit --prod --audit-level=high"
},
"dependencies": {
"@nestjs/common": "^11.1.0",

View File

@ -4,6 +4,11 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@prisma/config>deepmerge-ts': 8.0.0
prisma>mysql2: 3.23.1
'@nestjs/platform-express>multer': 2.3.0
importers:
.:
@ -22,7 +27,7 @@ importers:
version: 7.10.0
'@prisma/client':
specifier: ^7.0.0
version: 7.10.0(prisma@7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)
version: 7.10.0(prisma@7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)
dotenv:
specifier: ^17.0.0
version: 17.4.2
@ -80,7 +85,7 @@ importers:
version: 3.9.6
prisma:
specifier: ^7.0.0
version: 7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
version: 7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
supertest:
specifier: ^7.0.0
version: 7.2.2(supports-color@8.1.1)
@ -1514,8 +1519,8 @@ packages:
babel-plugin-macros:
optional: true
deepmerge-ts@7.1.5:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
deepmerge-ts@8.0.0:
resolution: {integrity: sha512-ICNjaP0ML+eSdEpJYQC46XiAn/UjAdwbEl0dE8p85ZTeNDinN4Kd4+9jS4OSAuH7st6eC7rQhsqTF5zIDaUm2g==}
engines: {node: '>=16.0.0'}
deepmerge@4.3.1:
@ -2173,13 +2178,15 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multer@2.2.0:
resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==}
multer@2.3.0:
resolution: {integrity: sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==}
engines: {node: '>= 10.16.0'}
mysql2@3.15.3:
resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==}
mysql2@3.23.1:
resolution: {integrity: sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==}
engines: {node: '>= 8.0'}
peerDependencies:
'@types/node': '>= 8'
named-placeholders@1.1.6:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
@ -2515,9 +2522,6 @@ packages:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
@ -2571,9 +2575,9 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
sql-escaper@1.5.1:
resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
@ -3414,7 +3418,7 @@ snapshots:
'@nestjs/core': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cors: 2.8.6
express: 5.2.1(supports-color@8.1.1)
multer: 2.2.0
multer: 2.3.0
path-to-regexp: 8.4.2
tslib: 2.8.1
transitivePeerDependencies:
@ -3506,17 +3510,17 @@ snapshots:
'@prisma/client-runtime-utils@7.10.0': {}
'@prisma/client@7.10.0(prisma@7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)':
'@prisma/client@7.10.0(prisma@7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@prisma/client-runtime-utils': 7.10.0
optionalDependencies:
prisma: 7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
prisma: 7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
typescript: 5.9.3
'@prisma/config@7.10.0':
dependencies:
c12: 3.3.4
deepmerge-ts: 7.1.5
deepmerge-ts: 8.0.0
effect: 3.20.0
empathic: 2.0.0
transitivePeerDependencies:
@ -4294,7 +4298,7 @@ snapshots:
dedent@1.7.2: {}
deepmerge-ts@7.1.5: {}
deepmerge-ts@8.0.0: {}
deepmerge@4.3.1: {}
@ -5123,15 +5127,16 @@ snapshots:
ms@2.1.3: {}
multer@2.2.0:
multer@2.3.0:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
type-is: 1.6.18
mysql2@3.15.3:
mysql2@3.23.1(@types/node@24.13.3):
dependencies:
'@types/node': 24.13.3
aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1
@ -5139,8 +5144,7 @@ snapshots:
long: 5.3.2
lru.min: 1.1.5
named-placeholders: 1.1.6
seq-queue: 0.0.5
sqlstring: 2.3.3
sql-escaper: 1.5.1
named-placeholders@1.1.6:
dependencies:
@ -5309,17 +5313,18 @@ snapshots:
'@jest/schemas': 30.5.0
ansi-styles: 5.2.0
prisma@7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
prisma@7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
dependencies:
'@prisma/config': 7.10.0
'@prisma/dev': 0.24.17(typescript@5.9.3)
'@prisma/engines': 7.10.0
'@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
mysql2: 3.15.3
mysql2: 3.23.1(@types/node@24.13.3)
postgres: 3.4.7
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@types/node'
- '@types/react'
- '@types/react-dom'
- magicast
@ -5443,8 +5448,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
seq-queue@0.0.5: {}
serve-static@2.2.1(supports-color@8.1.1):
dependencies:
encodeurl: 2.0.0
@ -5502,7 +5505,7 @@ snapshots:
sprintf-js@1.0.3: {}
sqlstring@2.3.3: {}
sql-escaper@1.5.1: {}
stack-utils@2.0.6:
dependencies:

View File

@ -4,3 +4,7 @@ allowBuilds:
esbuild: true
prisma: true
unrs-resolver: true
overrides:
'@prisma/config>deepmerge-ts': 8.0.0
'prisma>mysql2': 3.23.1
'@nestjs/platform-express>multer': 2.3.0

View File

@ -2,7 +2,7 @@ import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
schema: 'prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: process.env.DATABASE_URL },
});

20
prisma/addresses.prisma Normal file
View File

@ -0,0 +1,20 @@
model Address {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
recipient String @db.VarChar(160)
line1 String @db.VarChar(200)
line2 String @default("") @db.VarChar(200)
city String @db.VarChar(100)
region String @db.VarChar(100)
postalCode String @map("postal_code") @db.VarChar(20)
countryCode String @map("country_code") @db.Char(2)
phone String @db.VarChar(16)
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Cascade)
@@index([userId, organizationId, createdAt, id])
@@map("addresses")
}

67
prisma/catalog.prisma Normal file
View File

@ -0,0 +1,67 @@
enum ProductStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum GroupKind {
CATEGORY
COLLECTION
}
model Product {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(160)
slug String @db.VarChar(160)
description String @default("") @db.VarChar(5000)
status ProductStatus @default(DRAFT)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
variants ProductVariant[]
groups ProductGroup[]
@@unique([organizationId, slug])
@@unique([id, organizationId])
@@index([organizationId, status, createdAt, id])
@@map("products")
}
model ProductVariant {
id String @id @default(uuid()) @db.Uuid
productId String @map("product_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
sku String @db.VarChar(64)
name String @db.VarChar(160)
price Decimal @db.Decimal(12,2)
currency String @default("INR") @db.Char(3)
attributes Json @default("{}")
active Boolean @default(true)
stockItems StockItem[]
product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, sku])
@@unique([id, organizationId])
@@index([productId, active])
@@map("product_variants")
}
model CatalogGroup {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(100)
slug String @db.VarChar(100)
kind GroupKind
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
products ProductGroup[]
@@unique([organizationId, kind, slug])
@@unique([id, organizationId])
@@map("catalog_groups")
}
model ProductGroup {
productId String @map("product_id") @db.Uuid
groupId String @map("group_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Cascade)
group CatalogGroup @relation(fields: [groupId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@id([productId, groupId])
@@index([groupId, organizationId])
@@map("product_groups")
}

66
prisma/inventory.prisma Normal file
View File

@ -0,0 +1,66 @@
enum ReservationStatus {
ACTIVE
RELEASED
COMMITTED
}
model Warehouse {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(100)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
items StockItem[]
@@unique([organizationId, name])
@@unique([id, organizationId])
@@map("warehouses")
}
model StockItem {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
variantId String @map("variant_id") @db.Uuid
warehouseId String @map("warehouse_id") @db.Uuid
onHand Int @default(0) @map("on_hand")
variant ProductVariant @relation(fields: [variantId, organizationId], references: [id, organizationId], onDelete: Restrict)
warehouse Warehouse @relation(fields: [warehouseId, organizationId], references: [id, organizationId], onDelete: Restrict)
entries StockLedger[]
reservations StockReservation[]
@@unique([variantId, warehouseId])
@@unique([id, organizationId])
@@index([organizationId, id])
@@map("stock_items")
}
model StockLedger {
id String @id @default(uuid()) @db.Uuid
stockItemId String @map("stock_item_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
actorId String @map("actor_id") @db.Uuid
actor User @relation(fields: [actorId, organizationId], references: [id, organizationId], onDelete: Restrict)
delta Int
reason String @db.VarChar(200)
idempotencyKey String @map("idempotency_key") @db.Uuid
requestHash String @map("request_hash") @db.Char(64)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
stockItem StockItem @relation(fields: [stockItemId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, idempotencyKey])
@@index([stockItemId, createdAt, id])
@@map("stock_ledger")
}
model StockReservation {
id String @id @default(uuid()) @db.Uuid
stockItemId String @map("stock_item_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
userId String @map("user_id") @db.Uuid
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict)
quantity Int
status ReservationStatus @default(ACTIVE)
idempotencyKey String @map("idempotency_key") @db.Uuid
requestHash String @map("request_hash") @db.Char(64)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
stockItem StockItem @relation(fields: [stockItemId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, idempotencyKey])
@@index([stockItemId, status, expiresAt])
@@index([userId, organizationId])
@@map("stock_reservations")
}

View File

@ -0,0 +1,9 @@
{
"202609080003_identity_integrity": "872ee89b0b4a45f574d024d37b7f7d2536ce61df19d2ecefe59594edef06fc14",
"202609080001_create_organizations": "7ca0a406b175865c54d026df616c82f60c4fb276f37e6dd243602621ff8618f6",
"202609080002_identity_access": "e74393574bc5924497a59e71f36ad48310b401268560656102ed24fba84ff902",
"20260909141447_catalog_addresses": "c89cc9448494d74f8e5ee0005e81b6265bb01d7f000a81c4af27dc3c9855ba84",
"20260909141549_inventory": "ab5b0afde7332dd8de3190781dde95bd941bba91ab525308a7436d108cef7e36",
"20260909141622_commerce_integrity": "30545784aa33f35783c0170b80de2bded32be8781e25e027c4e5e2e5da8348ac",
"20260909153911_inventory_actor_scope": "7d6d2df3a8229032022f5a2ce43ed37c508c4a71743fd2bc6c24786634611d1e"
}

View File

@ -0,0 +1,122 @@
-- CreateEnum
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "GroupKind" AS ENUM ('CATEGORY', 'COLLECTION');
-- CreateTable
CREATE TABLE "addresses" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"recipient" VARCHAR(160) NOT NULL,
"line1" VARCHAR(200) NOT NULL,
"line2" VARCHAR(200) NOT NULL DEFAULT '',
"city" VARCHAR(100) NOT NULL,
"region" VARCHAR(100) NOT NULL,
"postal_code" VARCHAR(20) NOT NULL,
"country_code" CHAR(2) NOT NULL,
"phone" VARCHAR(16) NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"slug" VARCHAR(160) NOT NULL,
"description" VARCHAR(5000) NOT NULL DEFAULT '',
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_variants" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"sku" VARCHAR(64) NOT NULL,
"name" VARCHAR(160) NOT NULL,
"price" DECIMAL(12,2) NOT NULL,
"currency" CHAR(3) NOT NULL DEFAULT 'INR',
"attributes" JSONB NOT NULL DEFAULT '{}',
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "product_variants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "catalog_groups" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"slug" VARCHAR(100) NOT NULL,
"kind" "GroupKind" NOT NULL,
CONSTRAINT "catalog_groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_groups" (
"product_id" UUID NOT NULL,
"group_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
CONSTRAINT "product_groups_pkey" PRIMARY KEY ("product_id","group_id")
);
-- CreateIndex
CREATE INDEX "addresses_user_id_organization_id_created_at_id_idx" ON "addresses"("user_id", "organization_id", "created_at", "id");
-- CreateIndex
CREATE INDEX "products_organization_id_status_created_at_id_idx" ON "products"("organization_id", "status", "created_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "products_organization_id_slug_key" ON "products"("organization_id", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "products_id_organization_id_key" ON "products"("id", "organization_id");
-- CreateIndex
CREATE INDEX "product_variants_product_id_active_idx" ON "product_variants"("product_id", "active");
-- CreateIndex
CREATE UNIQUE INDEX "product_variants_organization_id_sku_key" ON "product_variants"("organization_id", "sku");
-- CreateIndex
CREATE UNIQUE INDEX "product_variants_id_organization_id_key" ON "product_variants"("id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "catalog_groups_organization_id_kind_slug_key" ON "catalog_groups"("organization_id", "kind", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "catalog_groups_id_organization_id_key" ON "catalog_groups"("id", "organization_id");
-- CreateIndex
CREATE INDEX "product_groups_group_id_organization_id_idx" ON "product_groups"("group_id", "organization_id");
-- AddForeignKey
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "products" ADD CONSTRAINT "products_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_variants" ADD CONSTRAINT "product_variants_product_id_organization_id_fkey" FOREIGN KEY ("product_id", "organization_id") REFERENCES "products"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "catalog_groups" ADD CONSTRAINT "catalog_groups_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_groups" ADD CONSTRAINT "product_groups_product_id_organization_id_fkey" FOREIGN KEY ("product_id", "organization_id") REFERENCES "products"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_groups" ADD CONSTRAINT "product_groups_group_id_organization_id_fkey" FOREIGN KEY ("group_id", "organization_id") REFERENCES "catalog_groups"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,98 @@
-- CreateEnum
CREATE TYPE "ReservationStatus" AS ENUM ('ACTIVE', 'RELEASED', 'COMMITTED');
-- CreateTable
CREATE TABLE "warehouses" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
CONSTRAINT "warehouses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_items" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"variant_id" UUID NOT NULL,
"warehouse_id" UUID NOT NULL,
"on_hand" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "stock_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_ledger" (
"id" UUID NOT NULL,
"stock_item_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"actor_id" UUID NOT NULL,
"delta" INTEGER NOT NULL,
"reason" VARCHAR(200) NOT NULL,
"idempotency_key" UUID NOT NULL,
"request_hash" CHAR(64) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stock_ledger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_reservations" (
"id" UUID NOT NULL,
"stock_item_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"quantity" INTEGER NOT NULL,
"status" "ReservationStatus" NOT NULL DEFAULT 'ACTIVE',
"idempotency_key" UUID NOT NULL,
"request_hash" CHAR(64) NOT NULL,
"expires_at" TIMESTAMPTZ(3) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stock_reservations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "warehouses_organization_id_name_key" ON "warehouses"("organization_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "warehouses_id_organization_id_key" ON "warehouses"("id", "organization_id");
-- CreateIndex
CREATE INDEX "stock_items_organization_id_id_idx" ON "stock_items"("organization_id", "id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_items_variant_id_warehouse_id_key" ON "stock_items"("variant_id", "warehouse_id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_items_id_organization_id_key" ON "stock_items"("id", "organization_id");
-- CreateIndex
CREATE INDEX "stock_ledger_stock_item_id_created_at_id_idx" ON "stock_ledger"("stock_item_id", "created_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_ledger_organization_id_idempotency_key_key" ON "stock_ledger"("organization_id", "idempotency_key");
-- CreateIndex
CREATE INDEX "stock_reservations_stock_item_id_status_expires_at_idx" ON "stock_reservations"("stock_item_id", "status", "expires_at");
-- CreateIndex
CREATE INDEX "stock_reservations_user_id_organization_id_idx" ON "stock_reservations"("user_id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_reservations_organization_id_idempotency_key_key" ON "stock_reservations"("organization_id", "idempotency_key");
-- AddForeignKey
ALTER TABLE "warehouses" ADD CONSTRAINT "warehouses_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_items" ADD CONSTRAINT "stock_items_variant_id_organization_id_fkey" FOREIGN KEY ("variant_id", "organization_id") REFERENCES "product_variants"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_items" ADD CONSTRAINT "stock_items_warehouse_id_organization_id_fkey" FOREIGN KEY ("warehouse_id", "organization_id") REFERENCES "warehouses"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_ledger" ADD CONSTRAINT "stock_ledger_stock_item_id_organization_id_fkey" FOREIGN KEY ("stock_item_id", "organization_id") REFERENCES "stock_items"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_stock_item_id_organization_id_fkey" FOREIGN KEY ("stock_item_id", "organization_id") REFERENCES "stock_items"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,27 @@
-- Positive prices, valid stock arithmetic and private-address defaults are
-- enforced in PostgreSQL in addition to request validation.
ALTER TABLE product_variants ADD CONSTRAINT variant_positive_price CHECK (price > 0);
ALTER TABLE stock_items ADD CONSTRAINT stock_nonnegative CHECK (on_hand >= 0);
ALTER TABLE stock_ledger ADD CONSTRAINT ledger_nonzero_delta CHECK (delta <> 0);
ALTER TABLE stock_reservations ADD CONSTRAINT reservation_positive_quantity CHECK (quantity > 0);
CREATE UNIQUE INDEX addresses_one_default ON addresses (user_id) WHERE is_default = true;
CREATE FUNCTION reject_stock_ledger_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Stock ledger entries are append-only';
END;
$$;
CREATE TRIGGER stock_ledger_append_only
BEFORE UPDATE OR DELETE ON stock_ledger
FOR EACH ROW EXECUTE FUNCTION reject_stock_ledger_mutation();
-- Existing system-owner roles need the new permissions. Custom roles retain
-- their explicit grants and must be updated through the authorized API.
UPDATE roles SET permissions = ARRAY(
SELECT DISTINCT permission FROM unnest(permissions || ARRAY[
'catalog.read', 'catalog.manage', 'catalog.publish',
'inventory.read', 'inventory.manage', 'inventory.adjust',
'inventory.reserve', 'inventory.commit'
]::text[]) AS permission ORDER BY permission
) WHERE is_system = true;

View File

@ -0,0 +1,5 @@
-- AddForeignKey
ALTER TABLE "stock_ledger" ADD CONSTRAINT "stock_ledger_actor_id_organization_id_fkey" FOREIGN KEY ("actor_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -19,6 +19,9 @@ model Organization {
users User[]
roles Role[]
auditEvents AuditEvent[]
products Product[]
catalogGroups CatalogGroup[]
warehouses Warehouse[]
@@map("organizations")
}
model User {
@ -35,6 +38,9 @@ model User {
roles UserRole[]
sessions Session[]
recoveryTokens RecoveryToken[]
addresses Address[]
stockEntries StockLedger[]
stockReservations StockReservation[]
@@unique([organizationId, email])
@@unique([id, organizationId])
@@index([organizationId, createdAt, id])

View File

@ -0,0 +1,42 @@
import { createHash } from 'node:crypto';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
const root = resolve(
process.argv.find((value) => value.startsWith('--root='))?.slice(7) ?? '.',
);
const folder = join(root, 'prisma', 'migrations');
const manifest = join(root, 'prisma', 'migration-checksums.json');
const hashes = JSON.parse(await readFile(manifest, 'utf8'));
const canonicalHash = (sql) =>
createHash('sha256').update(sql.replaceAll('\r\n', '\n')).digest('hex');
for (const [name, expected] of Object.entries(hashes)) {
const actual = canonicalHash(
await readFile(join(folder, name, 'migration.sql'), 'utf8'),
);
if (actual !== expected)
throw new Error(`Immutable migration changed: ${name}`);
}
const names = (await readdir(folder, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const stamps = new Set();
for (const name of names) {
const stamp = name.split('_')[0];
if (stamps.has(stamp))
throw new Error(`Duplicate migration timestamp: ${stamp}`);
stamps.add(stamp);
if (hashes[name]) continue;
if (!/^\d{14}_[a-z0-9_]+$/.test(name))
throw new Error(`New migration requires YYYYMMDDHHmmss timestamp: ${name}`);
if (!process.argv.includes('--record-new'))
throw new Error(`New migration checksum not recorded: ${name}`);
hashes[name] = canonicalHash(
await readFile(join(folder, name, 'migration.sql'), 'utf8'),
);
}
if (process.argv.includes('--record-new'))
await writeFile(manifest, JSON.stringify(hashes, null, 2) + '\n');
console.log(`Verified ${Object.keys(hashes).length} immutable migrations`);

View File

@ -10,7 +10,7 @@ const commands = [
'diff',
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'prisma',
'--exit-code',
],
];

View File

@ -0,0 +1,24 @@
import { z } from 'zod';
import { text } from '../common/input';
export const addressSchema = z
.object({
recipient: text(160),
line1: text(200),
line2: text(200, 0).default(''),
city: text(100),
region: text(100),
postalCode: z
.string()
.trim()
.min(1)
.max(20)
.regex(/^[A-Za-z0-9 -]+$/),
countryCode: z
.string()
.toUpperCase()
.regex(/^[A-Z]{2}$/),
phone: z.string().regex(/^\+[1-9]\d{6,14}$/),
isDefault: z.boolean().default(false),
})
.strict();
export type AddressInput = z.infer<typeof addressSchema>;

View File

@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { Prisma } from '../generated/prisma/client';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import type { AddressInput } from './address.schema';
async function ensureDefault(tx: Prisma.TransactionClient, userId: string) {
if (await tx.address.count({ where: { userId, isDefault: true } })) return;
const first = await tx.address.findFirst({
where: { userId },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
if (first)
await tx.address.update({
where: { id: first.id },
data: { isDefault: true },
});
}
@Injectable()
export class AddressStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal) {
return this.db.address.findMany({
where: { userId: actor.userId, organizationId: actor.organizationId },
orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }, { id: 'asc' }],
take: 20,
});
}
save(actor: Principal, input: AddressInput, id?: string) {
return this.access.mutate(actor, null, async (tx) => {
const where = {
userId: actor.userId,
organizationId: actor.organizationId,
};
if (id && !(await tx.address.findFirst({ where: { ...where, id } })))
throw new AppError('ADDRESS_NOT_FOUND');
if (!id && (await tx.address.count({ where })) >= 20)
throw new AppError('ADDRESS_LIMIT');
if (input.isDefault)
await tx.address.updateMany({ where, data: { isDefault: false } });
const address = id
? await tx.address.update({ where: { id }, data: input })
: await tx.address.create({ data: { ...where, ...input } });
await ensureDefault(tx, actor.userId);
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'address.updated' : 'address.created',
address.id,
);
return tx.address.findUniqueOrThrow({ where: { id: address.id } });
});
}
remove(actor: Principal, id: string) {
return this.access.mutate(actor, null, async (tx) => {
const removed = await tx.address.deleteMany({
where: {
id,
userId: actor.userId,
organizationId: actor.organizationId,
},
});
if (!removed.count) throw new AppError('ADDRESS_NOT_FOUND');
await ensureDefault(tx, actor.userId);
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'address.deleted',
id,
);
});
}
}

View File

@ -0,0 +1,48 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
Put,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { CurrentPrincipal } from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { AddressStore } from './address.store';
import { addressSchema, type AddressInput } from './address.schema';
@Controller('addresses')
export class AddressesController {
constructor(private readonly addresses: AddressStore) {}
@Get()
list(@CurrentPrincipal() actor: Principal) {
return this.addresses.list(actor);
}
@Post()
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(addressSchema)) input: AddressInput,
) {
return this.addresses.save(actor, input);
}
@Put(':id')
update(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(addressSchema)) input: AddressInput,
) {
return this.addresses.save(actor, input, id);
}
@Delete(':id')
@HttpCode(204)
remove(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.addresses.remove(actor, id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { IdentityModule } from '../identity/identity.module';
import { AddressStore } from './address.store';
import { AddressesController } from './addresses.controller';
@Module({
imports: [DatabaseModule, IdentityModule],
providers: [AddressStore],
controllers: [AddressesController],
})
export class AddressesModule {}

View File

@ -1,7 +1,19 @@
import { CatalogModule } from './catalog/catalog.module';
import { AddressesModule } from './addresses/addresses.module';
import { InventoryModule } from './inventory/inventory.module';
import { Module } from '@nestjs/common';
import { EnvironmentModule } from './config/environment.module';
import { IdentityModule } from './identity/identity.module';
import { HealthModule } from './health/health.module';
@Module({ imports: [EnvironmentModule, HealthModule, IdentityModule] })
@Module({
imports: [
EnvironmentModule,
HealthModule,
IdentityModule,
CatalogModule,
AddressesModule,
InventoryModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,17 @@
import type { Prisma } from '../generated/prisma/client';
import type { CatalogQuery } from './catalog.schemas';
export function catalogWhere(
organizationId: string,
query: CatalogQuery,
publishedOnly = false,
): Prisma.ProductWhereInput {
return {
organizationId,
...(publishedOnly ? { status: 'PUBLISHED' } : {}),
...(query.search
? { name: { contains: query.search, mode: 'insensitive' } }
: {}),
...(query.groupId ? { groups: { some: { groupId: query.groupId } } } : {}),
};
}

View File

@ -0,0 +1,31 @@
import type { Prisma } from '../generated/prisma/client';
import { AppError } from '../common/errors/app-error';
export async function scopedProduct(
tx: Prisma.TransactionClient,
organizationId: string,
id: string,
) {
const product = await tx.product.findFirst({ where: { id, organizationId } });
if (!product) throw new AppError('PRODUCT_NOT_FOUND');
return product;
}
export async function editableProduct(
tx: Prisma.TransactionClient,
organizationId: string,
id: string,
) {
const product = await scopedProduct(tx, organizationId, id);
if (product.status === 'ARCHIVED') throw new AppError('PRODUCT_ARCHIVED');
return product;
}
export async function validateGroups(
tx: Prisma.TransactionClient,
organizationId: string,
groupIds: string[],
) {
const count = await tx.catalogGroup.count({
where: { organizationId, id: { in: groupIds } },
});
if (count !== groupIds.length) throw new AppError('GROUP_NOT_FOUND');
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { IdentityModule } from '../identity/identity.module';
import { ProductStore } from './product.store';
import { VariantStore } from './variant.store';
import { GroupStore } from './group.store';
import { StorefrontStore } from './storefront.store';
import { ProductsController } from './products.controller';
import { GroupsController } from './groups.controller';
import { StorefrontController } from './storefront.controller';
import { StorefrontGuard } from './storefront.guard';
@Module({
imports: [DatabaseModule, IdentityModule],
providers: [
ProductStore,
VariantStore,
GroupStore,
StorefrontStore,
StorefrontGuard,
],
controllers: [ProductsController, GroupsController, StorefrontController],
})
export class CatalogModule {}

View File

@ -0,0 +1,64 @@
import { z } from 'zod';
import { text, ids } from '../common/input';
import { pageSchema } from '../identity/identity.schemas';
const slug = (max: number) =>
z
.string()
.max(max)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
export const productSchema = z
.object({
name: text(160),
slug: slug(160),
description: text(5000, 0).default(''),
groupIds: ids.default([]),
})
.strict();
export const productStatusSchema = z
.object({ status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']) })
.strict();
export const groupSchema = z
.object({
name: text(100),
slug: slug(100),
kind: z.enum(['CATEGORY', 'COLLECTION']),
})
.strict();
export const variantSchema = z
.object({
sku: z
.string()
.trim()
.toUpperCase()
.max(64)
.regex(/^[A-Z0-9][A-Z0-9_-]*$/),
name: text(160),
price: z
.string()
.regex(/^(0|[1-9]\d{0,9})\.\d{2}$/)
.refine((value) => value !== '0.00'),
currency: z.enum(['INR', 'USD', 'EUR', 'GBP']).default('INR'),
active: z.boolean().default(true),
attributes: z
.record(
z
.string()
.regex(/^[a-z][a-z0-9_]{0,31}$/)
.refine((key) => !['constructor', 'prototype'].includes(key)),
text(100),
)
.refine((value) => Object.keys(value).length <= 20)
.default({}),
})
.strict();
export const catalogQuery = pageSchema
.extend({
search: text(100).optional(),
groupId: z.uuid().optional(),
})
.strict();
export type ProductInput = z.infer<typeof productSchema>;
export type VariantInput = z.infer<typeof variantSchema>;
export type GroupInput = z.infer<typeof groupSchema>;
export type CatalogQuery = z.infer<typeof catalogQuery>;

View File

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import type { PageInput } from '../identity/identity.schemas';
import { AppError } from '../common/errors/app-error';
import type { GroupInput } from './catalog.schemas';
@Injectable()
export class GroupStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal, page: PageInput) {
return this.db.catalogGroup.findMany({
where: { organizationId: actor.organizationId },
orderBy: { id: 'asc' },
take: page.limit,
skip: page.offset,
});
}
save(actor: Principal, input: GroupInput, id?: string) {
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
if (
id &&
!(await tx.catalogGroup.findFirst({
where: { id, organizationId: actor.organizationId },
}))
) {
throw new AppError('GROUP_NOT_FOUND');
}
const group = id
? await tx.catalogGroup.update({ where: { id }, data: input })
: await tx.catalogGroup.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'catalog_group.updated' : 'catalog_group.created',
group.id,
);
return group;
});
}
}

View File

@ -0,0 +1,49 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import {
CurrentPrincipal,
RequirePermission,
} from '../identity/access.decorator';
import { pageSchema, type PageInput } from '../identity/identity.schemas';
import type { Principal } from '../identity/identity.types';
import { GroupStore } from './group.store';
import { groupSchema, type GroupInput } from './catalog.schemas';
@Controller('catalog-groups')
export class GroupsController {
constructor(private readonly groups: GroupStore) {}
@Get()
@RequirePermission('catalog.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.groups.list(actor, page);
}
@Post()
@RequirePermission('catalog.manage')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(groupSchema)) input: GroupInput,
) {
return this.groups.save(actor, input);
}
@Put(':id')
@RequirePermission('catalog.manage')
update(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(groupSchema)) input: GroupInput,
) {
return this.groups.save(actor, input, id);
}
}

View File

@ -0,0 +1,97 @@
import { catalogWhere } from './catalog-query';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import {
editableProduct,
scopedProduct,
validateGroups,
} from './catalog.helpers';
import type { CatalogQuery, ProductInput } from './catalog.schemas';
@Injectable()
export class ProductStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal, query: CatalogQuery) {
return this.db.product.findMany({
where: catalogWhere(actor.organizationId, query),
select: { id: true, name: true, slug: true, status: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: query.limit,
skip: query.offset,
});
}
async get(actor: Principal, id: string) {
await scopedProduct(this.db, actor.organizationId, id);
return this.db.product.findUnique({
where: { id },
include: {
variants: { orderBy: { id: 'asc' } },
groups: { include: { group: true } },
},
});
}
save(actor: Principal, input: ProductInput, id?: string) {
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
if (id) await editableProduct(tx, actor.organizationId, id);
await validateGroups(tx, actor.organizationId, input.groupIds);
const { groupIds, ...data } = input;
const product = id
? await tx.product.update({ where: { id }, data })
: await tx.product.create({
data: { ...data, organizationId: actor.organizationId },
});
await tx.productGroup.deleteMany({ where: { productId: product.id } });
await tx.productGroup.createMany({
data: groupIds.map((groupId) => ({
productId: product.id,
groupId,
organizationId: actor.organizationId,
})),
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'product.updated' : 'product.created',
product.id,
);
return product;
});
}
setStatus(
actor: Principal,
id: string,
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED',
) {
return this.access.mutate(actor, 'catalog.publish', async (tx) => {
await editableProduct(tx, actor.organizationId, id);
if (
status === 'PUBLISHED' &&
!(await tx.productVariant.count({
where: { productId: id, active: true },
}))
) {
throw new AppError('PRODUCT_NOT_PUBLISHABLE');
}
const product = await tx.product.update({
where: { id },
data: { status },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
`product.${status.toLowerCase()}`,
id,
);
return product;
});
}
}

View File

@ -0,0 +1,98 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import {
CurrentPrincipal,
RequirePermission,
} from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { ProductStore } from './product.store';
import { VariantStore } from './variant.store';
import {
catalogQuery,
productSchema,
productStatusSchema,
variantSchema,
type CatalogQuery,
type ProductInput,
type VariantInput,
} from './catalog.schemas';
@Controller('products')
export class ProductsController {
constructor(
private readonly products: ProductStore,
private readonly variants: VariantStore,
) {}
@Get()
@RequirePermission('catalog.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(catalogQuery)) query: CatalogQuery,
) {
return this.products.list(actor, query);
}
@Get(':id')
@RequirePermission('catalog.read')
get(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.products.get(actor, id);
}
@Post()
@RequirePermission('catalog.manage')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(productSchema)) input: ProductInput,
) {
return this.products.save(actor, input);
}
@Put(':id')
@RequirePermission('catalog.manage')
update(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(productSchema)) input: ProductInput,
) {
return this.products.save(actor, input, id);
}
@Patch(':id/status')
@RequirePermission('catalog.publish')
status(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(productStatusSchema))
input: { status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' },
) {
return this.products.setStatus(actor, id, input.status);
}
@Post(':id/variants')
@RequirePermission('catalog.manage')
createVariant(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(variantSchema)) input: VariantInput,
) {
return this.variants.save(actor, id, input);
}
@Put(':id/variants/:variantId')
@RequirePermission('catalog.manage')
updateVariant(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Param('variantId', ParseUUIDPipe) variantId: string,
@Body(new SchemaPipe(variantSchema)) input: VariantInput,
) {
return this.variants.save(actor, id, input, variantId);
}
}

View File

@ -0,0 +1,34 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
UseGuards,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { Public } from '../identity/access.decorator';
import { StorefrontStore } from './storefront.store';
import { StorefrontGuard } from './storefront.guard';
import { catalogQuery, type CatalogQuery } from './catalog.schemas';
@Public()
@UseGuards(StorefrontGuard)
@Controller('storefront/:organizationId/products')
export class StorefrontController {
constructor(private readonly storefront: StorefrontStore) {}
@Get()
list(
@Param('organizationId', ParseUUIDPipe) organizationId: string,
@Query(new SchemaPipe(catalogQuery)) query: CatalogQuery,
) {
return this.storefront.list(organizationId, query);
}
@Get(':id')
get(
@Param('organizationId', ParseUUIDPipe) organizationId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.storefront.get(organizationId, id);
}
}

View File

@ -0,0 +1,13 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { RateLimitService } from '../identity/rate-limit.service';
@Injectable()
export class StorefrontGuard implements CanActivate {
constructor(private readonly limits: RateLimitService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>();
await this.limits.consume(`storefront:${request.ip}`, 120, 60);
return true;
}
}

View File

@ -0,0 +1,50 @@
import { catalogWhere } from './catalog-query';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AppError } from '../common/errors/app-error';
import type { CatalogQuery } from './catalog.schemas';
const storefrontView = {
id: true,
name: true,
slug: true,
description: true,
variants: {
where: { active: true },
orderBy: { id: 'asc' as const },
select: {
id: true,
sku: true,
name: true,
price: true,
currency: true,
attributes: true,
},
},
groups: {
select: {
group: { select: { id: true, name: true, slug: true, kind: true } },
},
},
} as const;
@Injectable()
export class StorefrontStore {
constructor(private readonly db: DatabaseService) {}
list(organizationId: string, query: CatalogQuery) {
return this.db.product.findMany({
where: catalogWhere(organizationId, query, true),
select: { id: true, name: true, slug: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: query.limit,
skip: query.offset,
});
}
async get(organizationId: string, id: string) {
const product = await this.db.product.findFirst({
where: { id, organizationId, status: 'PUBLISHED' },
select: storefrontView,
});
if (!product) throw new AppError('PRODUCT_NOT_FOUND');
return product;
}
}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import { editableProduct } from './catalog.helpers';
import type { VariantInput } from './catalog.schemas';
@Injectable()
export class VariantStore {
constructor(private readonly access: AccessStore) {}
save(actor: Principal, productId: string, input: VariantInput, id?: string) {
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
const product = await editableProduct(
tx,
actor.organizationId,
productId,
);
if (
id &&
!(await tx.productVariant.findFirst({
where: { id, productId, organizationId: actor.organizationId },
}))
) {
throw new AppError('VARIANT_NOT_FOUND');
}
if (
!id &&
(await tx.productVariant.count({ where: { productId } })) >= 100
)
throw new AppError('VARIANT_LIMIT');
if (
id &&
!input.active &&
product.status === 'PUBLISHED' &&
!(await tx.productVariant.count({
where: { productId, active: true, id: { not: id } },
}))
) {
throw new AppError('VARIANT_REQUIRED');
}
const variant = id
? await tx.productVariant.update({ where: { id }, data: input })
: await tx.productVariant.create({
data: { ...input, productId, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'variant.updated' : 'variant.created',
variant.id,
);
return variant;
});
}
}

View File

@ -0,0 +1,51 @@
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { randomUUID, createHash } from 'node:crypto';
import type { Request, Response } from 'express';
import { classifyError } from './classify-error';
import { ERRORS } from './error-catalog';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('ApiException');
catch(error: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const request = http.getRequest<Request>();
const response = http.getResponse<Response>();
const failure = classifyError(error);
const requestId =
(response.locals.requestId as string | undefined) ?? randomUUID();
const record = {
event: failure.code,
message: ERRORS[failure.code][2],
diagnostic: failure.diagnostic,
requestId,
method: request.method,
route: request.route?.path ?? 'unmatched',
// Fingerprints distinguish unexpected faults without logging raw errors, queries or secrets.
faultId:
failure.code === 'INTERNAL_FAILURE' && error instanceof Error
? createHash('sha256')
.update(error.stack ?? error.name)
.digest('hex')
.slice(0, 16)
: undefined,
};
if (failure.getStatus() >= 500) this.logger.error(JSON.stringify(record));
else this.logger.warn(JSON.stringify(record));
if (response.headersSent) return;
response.setHeader('X-Request-Id', requestId);
response.setHeader('Cache-Control', 'no-store');
if (failure.getStatus() === 429)
response.setHeader(
'Retry-After',
String(failure.retryAfterSeconds ?? 60),
);
response.status(failure.getStatus()).json({
statusCode: failure.getStatus(),
code: failure.code,
message: failure.message,
requestId,
...(failure.fields ? { fields: failure.fields } : {}),
});
}
}

View File

@ -0,0 +1,16 @@
import { HttpException } from '@nestjs/common';
import { ERRORS, type ErrorCode } from './error-catalog';
export class AppError extends HttpException {
constructor(
readonly code: ErrorCode,
readonly diagnostic?: string,
readonly fields?: string[],
readonly retryAfterSeconds?: number,
) {
super(
{ code, message: ERRORS[code][1], ...(fields ? { fields } : {}) },
ERRORS[code][0],
);
}
}

View File

@ -0,0 +1,42 @@
import { HttpException } from '@nestjs/common';
import { Prisma } from '../../generated/prisma/client';
import { AppError } from './app-error';
import type { ErrorCode } from './error-catalog';
export function classifyError(error: unknown): AppError {
if (error instanceof AppError) return error;
if (error instanceof Prisma.PrismaClientKnownRequestError) {
const codes: Record<string, ErrorCode> = {
P2002: 'RECORD_CONFLICT',
P2003: 'REFERENCE_INVALID',
P2004: 'DATA_CONSTRAINT',
P2025: 'RECORD_NOT_FOUND',
P2024: 'DATABASE_BUSY',
P2028: 'DATABASE_BUSY',
P2034: 'TRANSACTION_CONFLICT',
};
return new AppError(codes[error.code] ?? 'INTERNAL_FAILURE', error.code);
}
if (error instanceof Prisma.PrismaClientInitializationError)
return new AppError('DATABASE_BUSY');
if (error instanceof HttpException) {
const codes: Record<number, ErrorCode> = {
400: 'REQUEST_MALFORMED',
401: 'AUTH_REQUIRED',
403: 'ACCESS_DENIED',
404: 'ROUTE_NOT_FOUND',
413: 'REQUEST_TOO_LARGE',
429: 'RATE_LIMITED',
503: 'DATABASE_BUSY',
};
return new AppError(codes[error.getStatus()] ?? 'INTERNAL_FAILURE');
}
// Express parser errors are not Nest HttpExceptions.
if (typeof error === 'object' && error !== null && 'type' in error) {
if (error.type === 'entity.too.large')
return new AppError('REQUEST_TOO_LARGE');
if (error.type === 'entity.parse.failed')
return new AppError('REQUEST_MALFORMED');
}
return new AppError('INTERNAL_FAILURE');
}

View File

@ -0,0 +1,84 @@
export const COMMERCE_ERRORS = {
PRODUCT_NOT_FOUND: [404, 'Product not found', 'Scoped product lookup failed'],
PRODUCT_NOT_PUBLISHABLE: [
409,
'Add an active priced variant before publishing',
'Product publish eligibility failed',
],
PRODUCT_ARCHIVED: [
409,
'Archived products cannot be edited',
'Archived product mutation rejected',
],
VARIANT_NOT_FOUND: [
404,
'Product variant not found',
'Scoped product variant lookup failed',
],
VARIANT_LIMIT: [
409,
'Product variant limit reached',
'Variant resource quota exceeded',
],
VARIANT_REQUIRED: [
409,
'A published product needs an active variant',
'Last sellable variant deactivation rejected',
],
GROUP_NOT_FOUND: [
404,
'Catalogue group not found',
'Scoped catalogue group lookup failed',
],
ADDRESS_NOT_FOUND: [
404,
'Address not found',
'Private address lookup failed',
],
ADDRESS_LIMIT: [
409,
'Address limit reached',
'Account address quota exceeded',
],
WAREHOUSE_NOT_FOUND: [
404,
'Warehouse not found',
'Scoped warehouse lookup failed',
],
STOCK_NOT_FOUND: [404, 'Stock item not found', 'Scoped stock lookup failed'],
STOCK_INSUFFICIENT: [
409,
'Insufficient available stock',
'Inventory availability check rejected operation',
],
STOCK_CAPACITY: [
409,
'Stock balance limit would be exceeded',
'Inventory integer bound rejected operation',
],
IDEMPOTENCY_CONFLICT: [
409,
'Idempotency key was used for a different request',
'Idempotency payload mismatch',
],
RESERVATION_NOT_FOUND: [
404,
'Stock reservation not found',
'Scoped reservation lookup failed',
],
RESERVATION_EXPIRED: [
409,
'Stock reservation has expired',
'Expired reservation commit rejected',
],
RESERVATION_CLOSED: [
409,
'Stock reservation is already closed',
'Invalid reservation state transition',
],
PRODUCT_UNAVAILABLE: [
409,
'Product variant is not available for reservation',
'Non-sellable variant reservation rejected',
],
} as const;

View File

@ -0,0 +1,4 @@
import { PLATFORM_ERRORS } from './platform-errors';
import { COMMERCE_ERRORS } from './commerce-errors';
export const ERRORS = { ...PLATFORM_ERRORS, ...COMMERCE_ERRORS } as const;
export type ErrorCode = keyof typeof ERRORS;

View File

@ -0,0 +1,122 @@
export const PLATFORM_ERRORS = {
REQUEST_INVALID: [400, 'Invalid request', 'Request schema validation failed'],
REQUEST_MALFORMED: [
400,
'Malformed request syntax',
'HTTP request parser rejected input',
],
REQUEST_TOO_LARGE: [
413,
'Request body exceeds the allowed size',
'HTTP payload limit exceeded',
],
ROUTE_NOT_FOUND: [404, 'API route not found', 'Unmatched HTTP route'],
AUTH_REQUIRED: [
401,
'Authentication is required',
'Bearer credential missing or malformed',
],
AUTH_INVALID_CREDENTIALS: [
401,
'Invalid credentials',
'Login credential verification failed',
],
SESSION_INVALID: [
401,
'Session is invalid or expired',
'Session authentication rejected',
],
ACCESS_DENIED: [
403,
'You do not have permission for this action',
'Permission check rejected operation',
],
SCOPE_DENIED: [
403,
'Account scope is not authorized',
'Transaction principal or organization mismatch',
],
RECORD_CONFLICT: [
409,
'A record with these details already exists',
'Database uniqueness conflict',
],
REFERENCE_INVALID: [
409,
'A related record is unavailable',
'Database foreign-key constraint rejected operation',
],
DATA_CONSTRAINT: [
409,
'Operation violates a data integrity rule',
'Database check constraint rejected operation',
],
RECORD_NOT_FOUND: [
404,
'Requested record is unavailable',
'Database record lookup failed',
],
DATABASE_BUSY: [
503,
'Database is temporarily busy; retry later',
'Database timeout or connection unavailable',
],
TRANSACTION_CONFLICT: [
409,
'Concurrent update detected; retry this operation',
'Database transaction conflict',
],
INTERNAL_FAILURE: [
500,
'An unexpected error occurred',
'Unhandled application failure',
],
USER_NOT_FOUND: [404, 'User not found', 'Scoped user lookup failed'],
ROLE_NOT_FOUND: [404, 'Role not found', 'Scoped role lookup failed'],
ROLE_GRANT_DENIED: [
403,
'Cannot grant permissions you do not hold',
'Role grant would exceed actor permissions',
],
ROLE_IMMUTABLE: [
403,
'System role is immutable',
'Attempt to modify protected system role',
],
ROLE_ASSIGNMENT_DENIED: [
403,
'Cannot change these role assignments',
'Protected or self role assignment rejected',
],
ROLE_SYSTEM_DENIED: [
403,
'System role cannot be assigned',
'Attempt to assign owner role',
],
USER_STATUS_DENIED: [
403,
'Cannot change this account status',
'Protected or self approval change rejected',
],
OWNER_EXISTS: [
409,
'Owner already exists',
'Installation bootstrap repeated',
],
RECOVERY_INVALID: [
400,
'Invalid or expired recovery token',
'Recovery token consumption rejected',
],
RECOVERY_UNAVAILABLE: [
503,
'Password recovery is not configured',
'SMTP recovery configuration missing',
],
RATE_LIMITED: [429, 'Too many requests', 'Durable request quota exceeded'],
DATABASE_NOT_READY: [
503,
'Service is not ready',
'Database readiness probe failed',
],
} as const;

12
src/common/input.ts Normal file
View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const text = (max: number, min = 1) =>
z
.string()
.trim()
.min(min)
.max(max)
.regex(/^[^<>\u0000-\u001F\u007F]*$/, 'Plain text only');
export const ids = z
.array(z.uuid())
.max(20)
.refine((values) => new Set(values).size === values.length);

View File

@ -1,17 +1,15 @@
import { BadRequestException, PipeTransform } from '@nestjs/common';
import type { PipeTransform } from '@nestjs/common';
import { z } from 'zod';
import { AppError } from './errors/app-error';
export class SchemaPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: z.ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: 'Invalid request',
fields: [
throw new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', [
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
],
});
]);
}
return result.data;
}

View File

@ -8,6 +8,7 @@ const schema = z
.enum(['development', 'test', 'production'])
.default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(50).default(10),
DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
CORS_ORIGINS: z
.string()

View File

@ -1,6 +1,10 @@
import type { INestApplication } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet';
import { randomUUID } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import type { Environment } from './config/environment';
import { ApiExceptionFilter } from './common/errors/api-exception.filter';
export function configureApp(
app: INestApplication,
@ -8,6 +12,19 @@ export function configureApp(
): void {
app.setGlobalPrefix('api/v1');
app.use(helmet());
app.use((_request: Request, response: Response, next: NextFunction) => {
response.locals.requestId = randomUUID();
response.setHeader('X-Request-Id', response.locals.requestId);
response.setHeader('Cache-Control', 'no-store');
next();
});
(app as NestExpressApplication).useBodyParser('json', { limit: '32kb' });
(app as NestExpressApplication).useBodyParser('urlencoded', {
limit: '32kb',
extended: false,
parameterLimit: 100,
});
app.useGlobalFilters(new ApiExceptionFilter());
app.enableCors({ origin: environment.CORS_ORIGINS, credentials: true });
app.enableShutdownHooks();
}

View File

@ -20,7 +20,7 @@ export class DatabaseService
connectionString: environment.DATABASE_URL,
connectionTimeoutMillis: 3000,
query_timeout: 3000,
max: 10,
max: environment.DATABASE_POOL_SIZE,
}),
});
}

View File

@ -1,4 +1,5 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
@Injectable()
@ -13,7 +14,7 @@ export class HealthService {
try {
await this.database.ping();
} catch {
throw new ServiceUnavailableException('Service is not ready');
throw new AppError('DATABASE_NOT_READY');
}
return { status: 'ok' };
}

View File

@ -1,10 +1,6 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { RateLimitService } from './rate-limit.service';
import { AppError } from '../common/errors/app-error';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SessionStore } from './session.store';
import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator';
@ -15,6 +11,7 @@ export class AccessGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionStore,
private readonly limits: RateLimitService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()];
@ -24,14 +21,20 @@ export class AccessGuard implements CanActivate {
const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(
request.headers.authorization ?? '',
);
if (!match) throw new UnauthorizedException();
if (!match) throw new AppError('AUTH_REQUIRED');
await this.limits.consume(`protected-ip:${request.ip}`, 300, 60);
request.principal = await this.sessions.authenticate(match[1]);
await this.limits.consume(
`protected-user:${request.principal.userId}`,
180,
60,
);
const permission = this.reflector.getAllAndOverride<string>(
REQUIRED_PERMISSION,
targets,
);
if (permission && !request.principal.permissions.includes(permission))
throw new ForbiddenException();
throw new AppError('ACCESS_DENIED');
return true;
}
}

View File

@ -1,11 +1,8 @@
import {
ConflictException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { Prisma } from '../generated/prisma/client';
import { readPrincipal } from './session.store';
import { AppError } from '../common/errors/app-error';
import type { Principal } from './identity.types';
import type { Permission } from './permissions';
@ -14,20 +11,24 @@ export class AccessStore {
constructor(private readonly db: DatabaseService) {}
async mutate<T>(
actor: Principal,
permission: Permission,
permission: Permission | null,
work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>,
lockOrganization = true,
): Promise<T> {
try {
return await this.db.$transaction(async (tx) => {
// Serialize administration within an organization and recheck permissions after locking.
if (lockOrganization) {
await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`;
}
const current = await readPrincipal(tx, actor.sessionId);
if (
current.organizationId !== actor.organizationId ||
!current.permissions.includes(permission)
current.userId !== actor.userId
) {
throw new ForbiddenException();
throw new AppError('SCOPE_DENIED');
}
if (permission && !current.permissions.includes(permission))
throw new AppError('ACCESS_DENIED');
return work(tx, current);
});
} catch (error) {
@ -35,9 +36,7 @@ export class AccessStore {
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(
'A record with these details already exists',
);
throw new AppError('RECORD_CONFLICT');
}
throw error;
}

View File

@ -1,6 +1,7 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { ENVIRONMENT } from '../config/environment.module';
import type { Environment } from '../config/environment';
import { AppError } from '../common/errors/app-error';
import { AuthStore } from './auth.store';
import { PasswordService } from './password.service';
import { RateLimitService } from './rate-limit.service';
@ -22,12 +23,18 @@ export class AuthService {
900,
);
const user = await this.store.findUser(input.organizationId, input.email);
const valid = user
? await this.passwords.verify(input.password, user.passwordHash)
: (await this.passwords.dummyVerify(input.password), false);
let valid = false;
if (user)
valid = await this.passwords.verify(input.password, user.passwordHash);
else await this.passwords.dummyVerify(input.password);
if (!valid || !user || user.status !== 'ACTIVE') {
if (user) await this.store.failedLogin(user.organizationId, user.id);
throw new UnauthorizedException('Invalid credentials');
const reason = !user
? 'ACCOUNT_UNKNOWN'
: !valid
? 'PASSWORD_MISMATCH'
: 'ACCOUNT_INACTIVE';
throw new AppError('AUTH_INVALID_CREDENTIALS', reason);
}
const { token, tokenHash } = issueToken();
const expiresAt = new Date(

View File

@ -1,4 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { recordAudit } from './audit';
import type { Principal } from './identity.types';
@ -21,7 +22,7 @@ export class AuthStore {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`;
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
if (user.status !== 'ACTIVE' || user.passwordHash !== expectedHash)
throw new UnauthorizedException();
throw new AppError('SESSION_INVALID', 'LOGIN_SNAPSHOT_CHANGED');
await tx.session.create({ data: { userId, tokenHash, expiresAt } });
await recordAudit(
tx,

View File

@ -1,4 +1,5 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { PasswordService } from './password.service';
import { PERMISSIONS } from './permissions';
@ -17,7 +18,7 @@ export class BootstrapService {
// One-time installation bootstrap, serialized across processes.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`;
if (await tx.user.count({ where: { isOwner: true } }))
throw new ConflictException('Owner already exists');
throw new AppError('OWNER_EXISTS');
const organization = await tx.organization.create({
data: { name: organizationName },
});

View File

@ -46,6 +46,6 @@ import { UsersController } from './users.controller';
UserStore,
{ provide: APP_GUARD, useClass: AccessGuard },
],
exports: [BootstrapService],
exports: [BootstrapService, AccessStore, RateLimitService],
})
export class IdentityModule {}

View File

@ -6,5 +6,13 @@ export const PERMISSIONS = [
'roles.read',
'roles.manage',
'audit.read',
'catalog.read',
'catalog.manage',
'catalog.publish',
'inventory.read',
'inventory.manage',
'inventory.adjust',
'inventory.reserve',
'inventory.commit',
] as const;
export type Permission = (typeof PERMISSIONS)[number];

View File

@ -1,4 +1,5 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { hashToken } from './tokens';
@ -7,19 +8,23 @@ export class RateLimitService {
constructor(private readonly db: DatabaseService) {}
async consume(key: string, limit: number, seconds: number): Promise<void> {
const digest = hashToken(key);
const [row] = await this.db.$queryRaw<Array<{ hits: number }>>`
const [row] = await this.db.$queryRaw<
Array<{ hits: number; expiresAt: Date }>
>`
INSERT INTO rate_limits (key, hits, expires_at)
VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second')
ON CONFLICT (key) DO UPDATE SET
hits = CASE WHEN rate_limits.expires_at <= NOW() THEN 1 ELSE rate_limits.hits + 1 END,
expires_at = CASE WHEN rate_limits.expires_at <= NOW()
THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END
RETURNING hits
RETURNING hits, expires_at AS "expiresAt"
`;
if (row.hits > limit)
throw new HttpException(
'Too many requests',
HttpStatus.TOO_MANY_REQUESTS,
throw new AppError(
'RATE_LIMITED',
key.split(':')[0],
undefined,
Math.max(1, Math.ceil((row.expiresAt.getTime() - Date.now()) / 1000)),
);
}
}

View File

@ -1,8 +1,5 @@
import {
Inject,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Inject, Injectable } from '@nestjs/common';
import { createTransport } from 'nodemailer';
import { ENVIRONMENT } from '../config/environment.module';
import type { Environment } from '../config/environment';
@ -11,10 +8,7 @@ import type { Environment } from '../config/environment';
export class RecoveryMailer {
constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {}
assertConfigured(): void {
if (!this.env.SMTP_HOST)
throw new ServiceUnavailableException(
'Password recovery is not configured',
);
if (!this.env.SMTP_HOST) throw new AppError('RECOVERY_UNAVAILABLE');
}
async send(email: string, token: string): Promise<void> {
this.assertConfigured();

View File

@ -41,7 +41,12 @@ export class RecoveryService {
} catch {
await this.store.discard(tokenHash);
// SMTP errors may contain credentials and addresses; do not log the raw error.
this.logger.error('Password recovery delivery failed');
this.logger.error(
JSON.stringify({
event: 'RECOVERY_DELIVERY_FAILED',
message: 'Password recovery delivery failed',
}),
);
}
}
async reset(token: string, password: string): Promise<void> {

View File

@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { recordAudit } from './audit';
@ -32,8 +33,7 @@ export class RecoveryStore {
async reset(tokenHash: string, passwordHash: string) {
await this.db.$transaction(async (tx) => {
const token = await tx.recoveryToken.findUnique({ where: { tokenHash } });
if (!token)
throw new BadRequestException('Invalid or expired recovery token');
if (!token) throw new AppError('RECOVERY_INVALID');
await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`;
const user = await tx.user.findUniqueOrThrow({
where: { id: token.userId },
@ -42,7 +42,7 @@ export class RecoveryStore {
where: { id: token.id, expiresAt: { gt: new Date() } },
});
if (consumed.count !== 1 || user.status !== 'ACTIVE') {
throw new BadRequestException('Invalid or expired recovery token');
throw new AppError('RECOVERY_INVALID');
}
await tx.user.update({ where: { id: user.id }, data: { passwordHash } });
await tx.recoveryToken.deleteMany({ where: { userId: user.id } });

View File

@ -1,8 +1,5 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from './access.store';
import { recordAudit } from './audit';
@ -13,7 +10,7 @@ function ensureGrantable(actor: Principal, permissions: string[]) {
if (
permissions.some((permission) => !actor.permissions.includes(permission))
) {
throw new ForbiddenException('Cannot grant permissions you do not hold');
throw new AppError('ROLE_GRANT_DENIED');
}
}
@Injectable()
@ -37,9 +34,8 @@ export class RoleStore {
const role = await tx.role.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!role) throw new NotFoundException();
if (role.isSystem)
throw new ForbiddenException('System role is immutable');
if (!role) throw new AppError('ROLE_NOT_FOUND');
if (role.isSystem) throw new AppError('ROLE_IMMUTABLE');
ensureGrantable(current, role.permissions);
}
const role = id
@ -66,9 +62,9 @@ export class RoleStore {
where: { id: userId, organizationId: actor.organizationId },
include: { roles: { include: { role: true } } },
});
if (!user) throw new NotFoundException();
if (!user) throw new AppError('USER_NOT_FOUND');
if (user.isOwner || user.id === current.userId)
throw new ForbiddenException('Cannot change these role assignments');
throw new AppError('ROLE_ASSIGNMENT_DENIED');
ensureGrantable(
current,
user.roles.flatMap((assignment) => assignment.role.permissions),
@ -76,9 +72,10 @@ export class RoleStore {
const roles = await tx.role.findMany({
where: { id: { in: roleIds }, organizationId: actor.organizationId },
});
if (roles.length !== roleIds.length) throw new NotFoundException();
if (roles.length !== roleIds.length)
throw new AppError('ROLE_NOT_FOUND');
if (roles.some((role) => role.isSystem))
throw new ForbiddenException('System role cannot be assigned');
throw new AppError('ROLE_SYSTEM_DENIED');
ensureGrantable(
current,
roles.flatMap((role) => role.permissions),

View File

@ -1,4 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { Prisma } from '../generated/prisma/client';
import { hashToken } from './tokens';
@ -18,7 +19,7 @@ export async function readPrincipal(
session.expiresAt <= new Date() ||
session.user.status !== 'ACTIVE'
) {
throw new UnauthorizedException();
throw new AppError('SESSION_INVALID');
}
return {
userId: session.userId,
@ -36,7 +37,7 @@ export class SessionStore {
const session = await this.db.session.findUnique({
where: { tokenHash: hashToken(token) },
});
if (!session) throw new UnauthorizedException();
if (!session) throw new AppError('SESSION_INVALID');
return readPrincipal(this.db, session.id);
}
}

View File

@ -1,8 +1,5 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from './access.store';
import { PasswordService } from './password.service';
@ -66,9 +63,9 @@ export class UserStore {
const user = await tx.user.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!user) throw new NotFoundException();
if (!user) throw new AppError('USER_NOT_FOUND');
if (user.isOwner || user.id === actor.userId)
throw new ForbiddenException('Cannot change this account status');
throw new AppError('USER_STATUS_DENIED');
const updated = await tx.user.update({
where: { id },
data: { status },

View File

@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import type { AdjustmentInput } from './inventory.schemas';
import { lockStock, reservedQuantity } from './stock-lock';
import { adjustedBalance, assertReplay, commandHash } from './inventory.policy';
@Injectable()
export class AdjustmentStore {
constructor(private readonly access: AccessStore) {}
adjust(actor: Principal, input: AdjustmentInput) {
const requestHash = commandHash(
input.stockItemId,
input.delta,
input.reason,
);
return this.access.mutate(
actor,
'inventory.adjust',
async (tx) => {
const stock = await lockStock(
tx,
actor.organizationId,
input.stockItemId,
);
const previous = await tx.stockLedger.findUnique({
where: {
organizationId_idempotencyKey: {
organizationId: actor.organizationId,
idempotencyKey: input.idempotencyKey,
},
},
});
if (previous) {
assertReplay(previous.requestHash, requestHash);
return previous;
}
const reserved = await reservedQuantity(tx, stock.id, new Date());
const onHand = adjustedBalance(stock.onHand, reserved, input.delta);
await tx.stockItem.update({
where: { id: stock.id },
data: { onHand },
});
const entry = await tx.stockLedger.create({
data: {
organizationId: actor.organizationId,
actorId: actor.userId,
...input,
requestHash,
},
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'stock.adjusted',
entry.id,
);
return entry;
},
false,
);
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { IdentityModule } from '../identity/identity.module';
import { StockStore } from './stock.store';
import { AdjustmentStore } from './adjustment.store';
import { ReservationStore } from './reservation.store';
import { ReservationTransitionStore } from './reservation-transition.store';
import { StockController } from './stock.controller';
import { ReservationsController } from './reservations.controller';
@Module({
imports: [DatabaseModule, IdentityModule],
providers: [
StockStore,
AdjustmentStore,
ReservationStore,
ReservationTransitionStore,
],
controllers: [StockController, ReservationsController],
})
export class InventoryModule {}

View File

@ -0,0 +1,19 @@
import { createHash } from 'node:crypto';
import { AppError } from '../common/errors/app-error';
export function commandHash(...parts: (string | number)[]): string {
return createHash('sha256').update(JSON.stringify(parts)).digest('hex');
}
export function assertReplay(expected: string, actual: string): void {
if (expected !== actual) throw new AppError('IDEMPOTENCY_CONFLICT');
}
export function adjustedBalance(
onHand: number,
reserved: number,
delta: number,
): number {
const next = onHand + delta;
if (next < reserved || next < 0) throw new AppError('STOCK_INSUFFICIENT');
if (next > 2_000_000_000) throw new AppError('STOCK_CAPACITY');
return next;
}

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
import { text } from '../common/input';
export const warehouseSchema = z.object({ name: text(100) }).strict();
export const stockSchema = z
.object({ variantId: z.uuid(), warehouseId: z.uuid() })
.strict();
export const adjustmentSchema = z
.object({
stockItemId: z.uuid(),
delta: z
.number()
.int()
.min(-1_000_000)
.max(1_000_000)
.refine((value) => value !== 0),
reason: text(200),
idempotencyKey: z.uuid(),
})
.strict();
export const reservationSchema = z
.object({
stockItemId: z.uuid(),
quantity: z.number().int().min(1).max(1_000_000),
ttlMinutes: z.number().int().min(1).max(60).default(15),
idempotencyKey: z.uuid(),
})
.strict();
export type AdjustmentInput = z.infer<typeof adjustmentSchema>;
export type ReservationInput = z.infer<typeof reservationSchema>;

View File

@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import { lockStock } from './stock-lock';
import { commandHash } from './inventory.policy';
import { reservationView } from './reservation.store';
@Injectable()
export class ReservationTransitionStore {
constructor(private readonly access: AccessStore) {}
transition(actor: Principal, id: string, commit: boolean) {
return this.access.mutate(
actor,
commit ? 'inventory.commit' : 'inventory.reserve',
async (tx) => {
const lookup = {
id,
organizationId: actor.organizationId,
...(!commit ? { userId: actor.userId } : {}),
};
const existing = await tx.stockReservation.findFirst({ where: lookup });
if (!existing) throw new AppError('RESERVATION_NOT_FOUND');
const stock = await lockStock(
tx,
actor.organizationId,
existing.stockItemId,
);
const row = await tx.stockReservation.findUniqueOrThrow({
where: { id },
});
const target = commit ? 'COMMITTED' : 'RELEASED';
if (row.status === target) return reservationView(row);
if (row.status !== 'ACTIVE') throw new AppError('RESERVATION_CLOSED');
if (commit) {
if (row.expiresAt <= new Date())
throw new AppError('RESERVATION_EXPIRED');
if (stock.onHand < row.quantity)
throw new AppError('STOCK_INSUFFICIENT');
await tx.stockItem.update({
where: { id: stock.id },
data: { onHand: { decrement: row.quantity } },
});
await tx.stockLedger.create({
data: {
stockItemId: stock.id,
organizationId: actor.organizationId,
actorId: actor.userId,
delta: -row.quantity,
reason: `Fulfil reservation ${id}`,
idempotencyKey: randomUUID(),
requestHash: commandHash(id, row.quantity),
},
});
}
const updated = await tx.stockReservation.update({
where: { id },
data: { status: target },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
commit ? 'reservation.committed' : 'reservation.released',
id,
);
return reservationView(updated);
},
false,
);
}
}

View File

@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { StockReservation } from '../generated/prisma/client';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import type { ReservationInput } from './inventory.schemas';
import { lockStock, reservedQuantity } from './stock-lock';
import { assertReplay, commandHash } from './inventory.policy';
export function reservationView(row: StockReservation) {
return {
...row,
status:
row.status === 'ACTIVE' && row.expiresAt <= new Date()
? 'EXPIRED'
: row.status,
};
}
@Injectable()
export class ReservationStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
async get(actor: Principal, id: string) {
const row = await this.db.stockReservation.findFirst({
where: {
id,
organizationId: actor.organizationId,
userId: actor.userId,
},
});
if (!row) throw new AppError('RESERVATION_NOT_FOUND');
return reservationView(row);
}
reserve(actor: Principal, input: ReservationInput) {
const requestHash = commandHash(
input.stockItemId,
actor.userId,
input.quantity,
input.ttlMinutes,
);
return this.access.mutate(
actor,
'inventory.reserve',
async (tx) => {
const stock = await lockStock(
tx,
actor.organizationId,
input.stockItemId,
);
const previous = await tx.stockReservation.findUnique({
where: {
organizationId_idempotencyKey: {
organizationId: actor.organizationId,
idempotencyKey: input.idempotencyKey,
},
},
});
if (previous) {
assertReplay(previous.requestHash, requestHash);
return reservationView(previous);
}
if (
!stock.variant.active ||
stock.variant.product.status !== 'PUBLISHED'
)
throw new AppError('PRODUCT_UNAVAILABLE');
const now = new Date();
const reserved = await reservedQuantity(tx, stock.id, now);
if (input.quantity > stock.onHand - reserved)
throw new AppError('STOCK_INSUFFICIENT');
const row = await tx.stockReservation.create({
data: {
stockItemId: stock.id,
organizationId: actor.organizationId,
userId: actor.userId,
quantity: input.quantity,
idempotencyKey: input.idempotencyKey,
requestHash,
expiresAt: new Date(now.getTime() + input.ttlMinutes * 60_000),
},
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'stock.reserved',
row.id,
);
return reservationView(row);
},
false,
);
}
}

View File

@ -0,0 +1,60 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import {
CurrentPrincipal,
RequirePermission,
} from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { ReservationStore } from './reservation.store';
import { ReservationTransitionStore } from './reservation-transition.store';
import { reservationSchema, type ReservationInput } from './inventory.schemas';
@Controller('inventory/reservations')
export class ReservationsController {
constructor(
private readonly reservations: ReservationStore,
private readonly transitions: ReservationTransitionStore,
) {}
@Post()
@RequirePermission('inventory.reserve')
reserve(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(reservationSchema)) input: ReservationInput,
) {
return this.reservations.reserve(actor, input);
}
@Get(':id')
@RequirePermission('inventory.reserve')
get(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.reservations.get(actor, id);
}
@Post(':id/release')
@HttpCode(200)
@RequirePermission('inventory.reserve')
release(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.transitions.transition(actor, id, false);
}
@Post(':id/commit')
@HttpCode(200)
@RequirePermission('inventory.commit')
commit(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.transitions.transition(actor, id, true);
}
}

View File

@ -0,0 +1,32 @@
import type { Prisma } from '../generated/prisma/client';
import { AppError } from '../common/errors/app-error';
export async function lockStock(
tx: Prisma.TransactionClient,
organizationId: string,
id: string,
) {
await tx.$queryRaw`SELECT id FROM stock_items WHERE id = ${id}::uuid
AND organization_id = ${organizationId}::uuid FOR UPDATE`;
const stock = await tx.stockItem.findFirst({
where: { id, organizationId },
include: { variant: { include: { product: true } } },
});
if (!stock) throw new AppError('STOCK_NOT_FOUND');
return stock;
}
export async function reservedQuantity(
tx: Prisma.TransactionClient,
stockItemId: string,
now: Date,
) {
const sum = await tx.stockReservation.aggregate({
where: {
stockItemId,
status: 'ACTIVE',
expiresAt: { gt: now },
},
_sum: { quantity: true },
});
return sum._sum.quantity ?? 0;
}

View File

@ -0,0 +1,90 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import {
CurrentPrincipal,
RequirePermission,
} from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { pageSchema, type PageInput } from '../identity/identity.schemas';
import { StockStore } from './stock.store';
import { AdjustmentStore } from './adjustment.store';
import {
warehouseSchema,
stockSchema,
adjustmentSchema,
type AdjustmentInput,
} from './inventory.schemas';
@Controller('inventory')
export class StockController {
constructor(
private readonly stocks: StockStore,
private readonly adjustments: AdjustmentStore,
) {}
@Get('warehouses')
@RequirePermission('inventory.read')
warehouses(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.stocks.warehouses(actor, page);
}
@Post('warehouses')
@RequirePermission('inventory.manage')
warehouse(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(warehouseSchema)) input: { name: string },
) {
return this.stocks.createWarehouse(actor, input.name);
}
@Get('stock-items')
@RequirePermission('inventory.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.stocks.list(actor, page);
}
@Post('stock-items')
@RequirePermission('inventory.manage')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(stockSchema))
input: { variantId: string; warehouseId: string },
) {
return this.stocks.create(actor, input);
}
@Get('stock-items/:id')
@RequirePermission('inventory.read')
get(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.stocks.get(actor, id);
}
@Get('stock-items/:id/ledger')
@RequirePermission('inventory.read')
ledger(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.stocks.ledger(actor, id, page);
}
@Post('adjustments')
@RequirePermission('inventory.adjust')
adjust(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(adjustmentSchema)) input: AdjustmentInput,
) {
return this.adjustments.adjust(actor, input);
}
}

View File

@ -0,0 +1,113 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import type { PageInput } from '../identity/identity.schemas';
import { AppError } from '../common/errors/app-error';
import { lockStock, reservedQuantity } from './stock-lock';
@Injectable()
export class StockStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
warehouses(actor: Principal, page: PageInput) {
return this.db.warehouse.findMany({
where: { organizationId: actor.organizationId },
orderBy: { id: 'asc' },
take: page.limit,
skip: page.offset,
});
}
createWarehouse(actor: Principal, name: string) {
return this.access.mutate(actor, 'inventory.manage', async (tx) => {
const warehouse = await tx.warehouse.create({
data: { organizationId: actor.organizationId, name },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'warehouse.created',
warehouse.id,
);
return warehouse;
});
}
create(actor: Principal, input: { warehouseId: string; variantId: string }) {
return this.access.mutate(actor, 'inventory.manage', async (tx) => {
if (
!(await tx.warehouse.findFirst({
where: {
id: input.warehouseId,
organizationId: actor.organizationId,
},
}))
) {
throw new AppError('WAREHOUSE_NOT_FOUND');
}
if (
!(await tx.productVariant.findFirst({
where: { id: input.variantId, organizationId: actor.organizationId },
}))
) {
throw new AppError('VARIANT_NOT_FOUND');
}
const stock = await tx.stockItem.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'stock.created',
stock.id,
);
return stock;
});
}
list(actor: Principal, page: PageInput) {
return this.db.stockItem.findMany({
where: { organizationId: actor.organizationId },
orderBy: { id: 'asc' },
take: page.limit,
skip: page.offset,
});
}
get(actor: Principal, id: string) {
return this.access.mutate(
actor,
'inventory.read',
async (tx) => {
const stock = await lockStock(tx, actor.organizationId, id);
const reserved = await reservedQuantity(tx, id, new Date());
return {
id,
variantId: stock.variantId,
warehouseId: stock.warehouseId,
onHand: stock.onHand,
reserved,
available: stock.onHand - reserved,
};
},
false,
);
}
async ledger(actor: Principal, id: string, page: PageInput) {
if (
!(await this.db.stockItem.findFirst({
where: { id, organizationId: actor.organizationId },
}))
) {
throw new AppError('STOCK_NOT_FOUND');
}
return this.db.stockLedger.findMany({
where: { stockItemId: id, organizationId: actor.organizationId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: page.limit,
skip: page.offset,
});
}
}

91
test/addresses.spec.ts Normal file
View File

@ -0,0 +1,91 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { addressInput, secondActor } from './helpers/commerce';
describe('private address book', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
const auth = { type: 'bearer' as const };
it('selects one default and selects a replacement after deletion', async () => {
const first = await ctx
.api()
.post('/api/v1/addresses')
.auth(ctx.token, auth)
.send(addressInput)
.expect(201);
expect(first.body.isDefault).toBe(true);
const second = await ctx
.api()
.post('/api/v1/addresses')
.auth(ctx.token, auth)
.send({ ...addressInput, line1: '13 Test Road', isDefault: true })
.expect(201);
const list = await ctx
.api()
.get('/api/v1/addresses')
.auth(ctx.token, auth)
.expect(200);
expect(
list.body.filter((row: { isDefault: boolean }) => row.isDefault),
).toHaveLength(1);
await ctx
.api()
.put(`/api/v1/addresses/${second.body.id}`)
.auth(ctx.token, auth)
.send({ ...addressInput, recipient: 'Changed', isDefault: true })
.expect(200);
await ctx
.api()
.delete(`/api/v1/addresses/${second.body.id}`)
.auth(ctx.token, auth)
.expect(204);
expect(
await ctx.db.address.findUnique({ where: { id: first.body.id } }),
).toMatchObject({ isDefault: true });
});
it('denies access to another user address, including within the same organization', async () => {
const owner = await ctx
.api()
.post('/api/v1/addresses')
.auth(ctx.token, auth)
.send(addressInput)
.expect(201);
const actor = await secondActor(ctx);
const own = await ctx
.api()
.get('/api/v1/addresses')
.auth(actor.token, auth)
.expect(200);
expect(own.body).toEqual([]);
await ctx
.api()
.put(`/api/v1/addresses/${owner.body.id}`)
.auth(actor.token, auth)
.send(addressInput)
.expect(404);
await ctx
.api()
.delete(`/api/v1/addresses/${owner.body.id}`)
.auth(actor.token, auth)
.expect(404);
});
it('rejects invalid contact fields and user-id injection', async () => {
await ctx
.api()
.post('/api/v1/addresses')
.auth(ctx.token, auth)
.send({ ...addressInput, phone: '123' })
.expect(400);
await ctx
.api()
.post('/api/v1/addresses')
.auth(ctx.token, auth)
.send({ ...addressInput, userId: ctx.owner.userId })
.expect(400);
await ctx.api().get('/api/v1/addresses').expect(401);
});
});

View File

@ -77,7 +77,7 @@ describe('administration use-case policy', () => {
it('hides users outside the organization scope', async () => {
tx.user.findFirst.mockResolvedValue(null);
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
'Not Found',
/not found/i,
);
expect(tx.user.update).not.toHaveBeenCalled();
});
@ -105,7 +105,7 @@ describe('administration use-case policy', () => {
tx.role.findFirst.mockResolvedValue(null);
await expect(
roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'),
).rejects.toThrow('Not Found');
).rejects.toThrow(/not found/i);
});
it('does not remove a target user permissions the actor cannot grant', async () => {
tx.user.findFirst.mockResolvedValue({
@ -121,7 +121,7 @@ describe('administration use-case policy', () => {
it('hides missing assignment targets', async () => {
tx.user.findFirst.mockResolvedValue(null);
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
'Not Found',
/not found/i,
);
});
});

192
test/catalog.spec.ts Normal file
View File

@ -0,0 +1,192 @@
import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { secondActor, seedProduct, variantInput } from './helpers/commerce';
describe('catalogue API', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
const auth = { type: 'bearer' as const };
it('keeps drafts private and publishes only sellable products', async () => {
const { product, variant } = await seedProduct(ctx);
await ctx
.api()
.get(
`/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`,
)
.expect(404);
await ctx
.api()
.patch(`/api/v1/products/${product.id}/status`)
.auth(ctx.token, auth)
.send({ status: 'PUBLISHED' })
.expect(200);
const response = await ctx
.api()
.get(
`/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`,
)
.expect(200);
expect(response.body.variants[0].price).toBe('499');
expect(response.body.variants[0].id).toBe(variant.id);
expect(response.body).not.toHaveProperty('organizationId');
expect(response.body).not.toHaveProperty('status');
await ctx
.api()
.get(`/api/v1/storefront/${randomUUID()}/products/${product.id}`)
.expect(404);
});
it('rejects publishing without a variant and deactivating the final published variant', async () => {
const empty = await ctx
.api()
.post('/api/v1/products')
.auth(ctx.token, auth)
.send({ name: 'Empty', slug: randomUUID() })
.expect(201);
const denied = await ctx
.api()
.patch(`/api/v1/products/${empty.body.id}/status`)
.auth(ctx.token, auth)
.send({ status: 'PUBLISHED' })
.expect(409);
expect(denied.body.code).toBe('PRODUCT_NOT_PUBLISHABLE');
const { product, variant } = await seedProduct(ctx, true);
await ctx
.api()
.put(`/api/v1/products/${product.id}/variants/${variant.id}`)
.auth(ctx.token, auth)
.send({ ...variantInput, sku: variant.sku, active: false })
.expect(409);
});
it('updates products, variants and groups using explicit organization scope', async () => {
const { product, variant } = await seedProduct(ctx);
const group = await ctx
.api()
.post('/api/v1/catalog-groups')
.auth(ctx.token, auth)
.send({ name: 'Festive', slug: randomUUID(), kind: 'COLLECTION' })
.expect(201);
await ctx
.api()
.put(`/api/v1/products/${product.id}`)
.auth(ctx.token, auth)
.send({ name: 'Updated', slug: product.slug, groupIds: [group.body.id] })
.expect(200);
await ctx
.api()
.put(`/api/v1/products/${product.id}/variants/${variant.id}`)
.auth(ctx.token, auth)
.send({ ...variantInput, sku: variant.sku, price: '599.50' })
.expect(200);
await ctx
.api()
.put(`/api/v1/catalog-groups/${group.body.id}`)
.auth(ctx.token, auth)
.send({ name: 'Diwali', slug: group.body.slug, kind: 'COLLECTION' })
.expect(200);
const filtered = await ctx
.api()
.get(`/api/v1/products?search=Updated&groupId=${group.body.id}`)
.auth(ctx.token, auth)
.expect(200);
expect(filtered.body.map((row: { id: string }) => row.id)).toContain(
product.id,
);
const detail = await ctx
.api()
.get(`/api/v1/products/${product.id}`)
.auth(ctx.token, auth)
.expect(200);
expect(detail.body.groups[0].group.name).toBe('Diwali');
await ctx
.api()
.get('/api/v1/catalog-groups?limit=1')
.auth(ctx.token, auth)
.expect(200);
});
it('denies unprivileged and cross-organization mutations', async () => {
const { product } = await seedProduct(ctx);
const outsider = await secondActor(ctx, false, [
'catalog.manage',
'catalog.read',
]);
const noRole = await secondActor(ctx);
await ctx
.api()
.get('/api/v1/products')
.auth(noRole.token, auth)
.expect(403);
await ctx
.api()
.put(`/api/v1/products/${product.id}`)
.auth(outsider.token, auth)
.send({ name: 'Hijacked', slug: product.slug })
.expect(404);
await ctx
.api()
.get(`/api/v1/products/${product.id}`)
.auth(outsider.token, auth)
.expect(404);
});
it('validates money, text, mass assignment and duplicates', async () => {
const { product, variant } = await seedProduct(ctx);
await ctx
.api()
.post(`/api/v1/products/${product.id}/variants`)
.auth(ctx.token, auth)
.send({ ...variantInput, price: 0.1 })
.expect(400);
await ctx
.api()
.post(`/api/v1/products/${product.id}/variants`)
.auth(ctx.token, auth)
.send({ ...variantInput, sku: variant.sku })
.expect(409);
await ctx
.api()
.post('/api/v1/products')
.auth(ctx.token, auth)
.send({ name: '<script>alert(1)</script>', slug: 'unsafe' })
.expect(400);
await ctx
.api()
.post('/api/v1/products')
.auth(ctx.token, auth)
.send({ name: 'Unsafe', slug: 'unsafe', organizationId: randomUUID() })
.expect(400);
});
it('archives products and omits drafts from public listing', async () => {
const { product } = await seedProduct(ctx, true);
await ctx
.api()
.get(
`/api/v1/storefront/${ctx.owner.organizationId}/products?search=Rose&limit=5`,
)
.expect(200);
await ctx
.api()
.patch(`/api/v1/products/${product.id}/status`)
.auth(ctx.token, auth)
.send({ status: 'ARCHIVED' })
.expect(200);
await ctx
.api()
.put(`/api/v1/products/${product.id}`)
.auth(ctx.token, auth)
.send({ name: 'Again', slug: product.slug })
.expect(409);
await ctx
.api()
.get(
`/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`,
)
.expect(404);
});
});

View File

@ -0,0 +1,81 @@
import { ProductStore } from '../src/catalog/product.store';
import { VariantStore } from '../src/catalog/variant.store';
import { AddressStore } from '../src/addresses/address.store';
import { StockStore } from '../src/inventory/stock.store';
import { AccessStore } from '../src/identity/access.store';
import { DatabaseService } from '../src/database/database.service';
import type { Principal } from '../src/identity/identity.types';
import { addressInput, variantInput } from './helpers/commerce';
describe('commerce use-case constraints', () => {
const actor: Principal = {
userId: 'user',
organizationId: 'org',
sessionId: 'session',
permissions: [],
};
const tx = {
product: { findFirst: jest.fn() },
catalogGroup: { count: jest.fn() },
productVariant: { count: jest.fn(), findFirst: jest.fn() },
address: { count: jest.fn(), findFirst: jest.fn() },
warehouse: { findFirst: jest.fn() },
};
const access = { mutate: jest.fn() };
beforeEach(() => {
jest.resetAllMocks();
access.mutate.mockImplementation((_actor, _permission, work) =>
work(tx, actor),
);
tx.product.findFirst.mockResolvedValue({ id: 'product', status: 'DRAFT' });
});
it('rejects group links outside the organization', async () => {
tx.catalogGroup.count.mockResolvedValue(0);
await expect(
new ProductStore(
{} as DatabaseService,
access as unknown as AccessStore,
).save(actor, {
name: 'Product',
slug: 'product',
description: '',
groupIds: ['foreign'],
}),
).rejects.toThrow('Catalogue group not found');
});
it('limits variants and hides unknown variant IDs', async () => {
const store = new VariantStore(access as unknown as AccessStore);
tx.productVariant.count.mockResolvedValue(100);
await expect(store.save(actor, 'product', variantInput)).rejects.toThrow(
'variant limit',
);
tx.productVariant.findFirst.mockResolvedValue(null);
await expect(
store.save(actor, 'product', variantInput, 'foreign'),
).rejects.toThrow('variant not found');
});
it('limits user address count', async () => {
tx.address.count.mockResolvedValue(20);
await expect(
new AddressStore(
{} as DatabaseService,
access as unknown as AccessStore,
).save(actor, addressInput),
).rejects.toThrow('Address limit');
});
it('rejects foreign warehouses and variants before creating stock', async () => {
const store = new StockStore(
{} as DatabaseService,
access as unknown as AccessStore,
);
tx.warehouse.findFirst.mockResolvedValue(null);
await expect(
store.create(actor, { warehouseId: 'bad', variantId: 'variant' }),
).rejects.toThrow('Warehouse not found');
tx.warehouse.findFirst.mockResolvedValue({ id: 'warehouse' });
tx.productVariant.findFirst.mockResolvedValue(null);
await expect(
store.create(actor, { warehouseId: 'warehouse', variantId: 'bad' }),
).rejects.toThrow('variant not found');
});
});

109
test/error-handling.spec.ts Normal file
View File

@ -0,0 +1,109 @@
import { ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { ApiExceptionFilter } from '../src/common/errors/api-exception.filter';
import { AppError } from '../src/common/errors/app-error';
import { ERRORS } from '../src/common/errors/error-catalog';
import { classifyError } from '../src/common/errors/classify-error';
import { Prisma } from '../src/generated/prisma/client';
describe('safe error responses and logs', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'error').mockImplementation();
});
afterEach(() => jest.restoreAllMocks());
function dispatch(error: unknown, headersSent = false) {
const response = {
locals: { requestId: 'server-generated-id' },
headersSent,
setHeader: jest.fn(),
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const host = {
switchToHttp: () => ({
getRequest: () => ({
method: 'POST',
route: { path: '/products/:id' },
body: { password: 'SECRET' },
headers: { authorization: 'Bearer SECRET' },
originalUrl: '/products?token=SECRET',
}),
getResponse: () => response,
}),
} as unknown as ArgumentsHost;
new ApiExceptionFilter().catch(error, host);
return response;
}
it('returns stable codes and correlation IDs without leaking raw faults', () => {
const response = dispatch(new Error('SQL password=SECRET token=SECRET'));
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
code: 'INTERNAL_FAILURE',
requestId: 'server-generated-id',
statusCode: 500,
}),
);
const output = JSON.stringify([
response.json.mock.calls,
jest.mocked(Logger.prototype.error).mock.calls,
]);
expect(output).not.toContain('SECRET');
expect(output).not.toContain('password=');
expect(output).toContain('faultId');
});
it('keeps validation field names and unique business error messages', () => {
const response = dispatch(
new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', ['price']),
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ fields: ['price'], code: 'REQUEST_INVALID' }),
);
const messages = Object.values(ERRORS).map((value) => value[1]);
expect(new Set(messages).size).toBe(messages.length);
});
it('does not try to write a second response', () => {
expect(
dispatch(new AppError('STOCK_INSUFFICIENT'), true).json,
).not.toHaveBeenCalled();
});
it('adds retry guidance for throttling', () => {
expect(
dispatch(new AppError('RATE_LIMITED')).setHeader,
).toHaveBeenCalledWith('Retry-After', '60');
});
it.each([
['P2002', 'RECORD_CONFLICT'],
['P2003', 'REFERENCE_INVALID'],
['P2004', 'DATA_CONSTRAINT'],
['P2025', 'RECORD_NOT_FOUND'],
['P2028', 'DATABASE_BUSY'],
['P2034', 'TRANSACTION_CONFLICT'],
['P9999', 'INTERNAL_FAILURE'],
])('classifies database error %s as %s', (databaseCode, code) => {
const error = new Prisma.PrismaClientKnownRequestError('SECRET', {
code: databaseCode,
clientVersion: 'test',
});
expect(classifyError(error).code).toBe(code);
});
it('classifies parser, transport and connection failures', () => {
expect(classifyError({ type: 'entity.too.large' }).code).toBe(
'REQUEST_TOO_LARGE',
);
expect(classifyError({ type: 'entity.parse.failed' }).code).toBe(
'REQUEST_MALFORMED',
);
expect(classifyError(new HttpException('SECRET', 404)).code).toBe(
'ROUTE_NOT_FOUND',
);
expect(classifyError(new HttpException('SECRET', 502)).code).toBe(
'INTERNAL_FAILURE',
);
expect(
classifyError(
new Prisma.PrismaClientInitializationError('SECRET', 'test'),
).code,
).toBe('DATABASE_BUSY');
expect(classifyError(null).code).toBe('INTERNAL_FAILURE');
});
});

113
test/helpers/commerce.ts Normal file
View File

@ -0,0 +1,113 @@
import { randomUUID } from 'node:crypto';
import type { IdentityApp } from './identity-app';
import { issueToken } from '../../src/identity/tokens';
export const variantInput = {
sku: 'CANDLE',
name: 'Rose 200g',
price: '499.00',
currency: 'INR' as const,
active: true,
attributes: { fragrance: 'rose' },
};
export const addressInput = {
recipient: 'Test Customer',
line1: '12 Test Road',
line2: '',
city: 'Pune',
region: 'Maharashtra',
postalCode: '411001',
countryCode: 'IN',
phone: '+919876543210',
isDefault: false,
};
export async function seedProduct(ctx: IdentityApp, publish = false) {
const slug = 'candle-' + randomUUID();
const product = await ctx
.api()
.post('/api/v1/products')
.auth(ctx.token, { type: 'bearer' })
.send({ name: 'Rose Candle', slug, description: 'Handmade candle' })
.expect(201);
const variant = await ctx
.api()
.post(`/api/v1/products/${product.body.id}/variants`)
.auth(ctx.token, { type: 'bearer' })
.send({ ...variantInput, sku: randomUUID() })
.expect(201);
if (publish)
await ctx
.api()
.patch(`/api/v1/products/${product.body.id}/status`)
.auth(ctx.token, { type: 'bearer' })
.send({ status: 'PUBLISHED' })
.expect(200);
return { product: product.body, variant: variant.body };
}
export async function seedStock(ctx: IdentityApp, onHand = 10) {
const catalog = await seedProduct(ctx, true);
const warehouse = await ctx
.api()
.post('/api/v1/inventory/warehouses')
.auth(ctx.token, { type: 'bearer' })
.send({ name: randomUUID() })
.expect(201);
const stock = await ctx
.api()
.post('/api/v1/inventory/stock-items')
.auth(ctx.token, { type: 'bearer' })
.send({ warehouseId: warehouse.body.id, variantId: catalog.variant.id })
.expect(201);
if (onHand)
await ctx
.api()
.post('/api/v1/inventory/adjustments')
.auth(ctx.token, { type: 'bearer' })
.send({
stockItemId: stock.body.id,
delta: onHand,
reason: 'Opening balance',
idempotencyKey: randomUUID(),
})
.expect(201);
return { ...catalog, warehouse: warehouse.body, stock: stock.body };
}
export async function secondActor(
ctx: IdentityApp,
sameOrganization = true,
permissions: string[] = [],
) {
const organizationId = sameOrganization
? ctx.owner.organizationId
: (
await ctx.db.organization.create({
data: { name: 'Other organization' },
})
).id;
const user = await ctx.db.user.create({
data: {
organizationId,
email: randomUUID() + '@example.com',
name: 'Test account',
passwordHash: 'not-a-login-credential',
status: 'ACTIVE',
},
});
if (permissions.length) {
const role = await ctx.db.role.create({
data: { organizationId, name: randomUUID(), permissions },
});
await ctx.db.userRole.create({
data: { organizationId, userId: user.id, roleId: role.id },
});
}
const token = issueToken();
await ctx.db.session.create({
data: {
userId: user.id,
tokenHash: token.tokenHash,
expiresAt: new Date(Date.now() + 60000),
},
});
return { token: token.token, userId: user.id, organizationId };
}

View File

@ -16,6 +16,7 @@ export async function identityApp() {
const env = parseEnvironment({
DATABASE_URL: database.connectionUrl,
NODE_ENV: 'test',
DATABASE_POOL_SIZE: process.env.TEST_DATABASE_URL ? '10' : '1',
});
const mailer = {
assertConfigured: jest.fn(),
@ -28,6 +29,7 @@ export async function identityApp() {
.useValue(mailer)
.compile();
const app = module.createNestApplication();
app.useLogger(false);
configureApp(app, env);
await app.init();
const db = app.get(DatabaseService);
@ -51,6 +53,7 @@ export async function identityApp() {
app,
db,
executeSql: database.execute,
owner,
token,
api,

View File

@ -58,7 +58,8 @@ describe('authentication with migrated PostgreSQL engine', () => {
const missing = await ctx.login('missing@example.com');
const wrong = await ctx.login('owner@example.com', 'wrong password');
expect(missing.status).toBe(401);
expect(wrong.body).toEqual(missing.body);
expect(wrong.body.code).toBe(missing.body.code);
expect(wrong.body.message).toBe(missing.body.message);
});
it('validates payloads without echoing secrets and rejects mass assignment', async () => {
const response = await ctx

View File

@ -0,0 +1,53 @@
import { randomUUID } from 'node:crypto';
import {
adjustedBalance,
assertReplay,
commandHash,
} from '../src/inventory/inventory.policy';
import {
reservationSchema,
adjustmentSchema,
} from '../src/inventory/inventory.schemas';
import { variantSchema } from '../src/catalog/catalog.schemas';
describe('inventory and catalogue input policy', () => {
it('protects reserved stock and integer capacity', () => {
expect(adjustedBalance(10, 5, -5)).toBe(5);
expect(() => adjustedBalance(10, 5, -6)).toThrow(
'Insufficient available stock',
);
expect(() => adjustedBalance(0, 0, -1)).toThrow();
expect(() => adjustedBalance(2_000_000_000, 0, 1)).toThrow(
'Stock balance limit',
);
});
it('distinguishes idempotent request payloads', () => {
const hash = commandHash('stock', 1, 'reason');
expect(() => assertReplay(hash, hash)).not.toThrow();
expect(() => assertReplay(hash, commandHash('stock', 2, 'reason'))).toThrow(
'different request',
);
});
it('bounds reservation quantity and duration', () => {
const input = {
stockItemId: randomUUID(),
quantity: 1,
idempotencyKey: randomUUID(),
};
expect(reservationSchema.parse(input).ttlMinutes).toBe(15);
expect(
reservationSchema.safeParse({ ...input, ttlMinutes: 61 }).success,
).toBe(false);
expect(
adjustmentSchema.safeParse({ ...input, delta: 0, reason: 'bad' }).success,
).toBe(false);
});
it.each(['0.00', '-1.00', '1.001', '01.00', '10000000000.00'])(
'rejects invalid prices %s',
(price) => {
expect(
variantSchema.safeParse({ name: 'Test', sku: 'SKU', price }).success,
).toBe(false);
},
);
});

255
test/inventory.spec.ts Normal file
View File

@ -0,0 +1,255 @@
import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { seedStock, secondActor } from './helpers/commerce';
describe('stock ledger and reservations', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
const auth = { type: 'bearer' as const };
const reserve = (
stockItemId: string,
quantity: number,
idempotencyKey = randomUUID(),
) =>
ctx
.api()
.post('/api/v1/inventory/reservations')
.auth(ctx.token, auth)
.send({ stockItemId, quantity, idempotencyKey });
it('replays identical adjustments once and rejects changed payloads', async () => {
const { stock } = await seedStock(ctx);
const input = {
stockItemId: stock.id,
delta: 5,
reason: 'Count correction',
idempotencyKey: randomUUID(),
};
const first = await ctx
.api()
.post('/api/v1/inventory/adjustments')
.auth(ctx.token, auth)
.send(input)
.expect(201);
const again = await ctx
.api()
.post('/api/v1/inventory/adjustments')
.auth(ctx.token, auth)
.send(input)
.expect(201);
expect(again.body.id).toBe(first.body.id);
const conflict = await ctx
.api()
.post('/api/v1/inventory/adjustments')
.auth(ctx.token, auth)
.send({ ...input, delta: 6 })
.expect(409);
expect(conflict.body.code).toBe('IDEMPOTENCY_CONFLICT');
const balance = await ctx
.api()
.get(`/api/v1/inventory/stock-items/${stock.id}`)
.auth(ctx.token, auth)
.expect(200);
expect(balance.body).toMatchObject({
onHand: 15,
reserved: 0,
available: 15,
});
});
it('prevents overselling and adjustments below active reservations', async () => {
const { stock } = await seedStock(ctx);
const key = randomUUID();
const first = await reserve(stock.id, 7, key).expect(201);
expect((await reserve(stock.id, 7, key).expect(201)).body.id).toBe(
first.body.id,
);
await reserve(stock.id, 8, key).expect(409);
const denied = await reserve(stock.id, 4).expect(409);
expect(denied.body.code).toBe('STOCK_INSUFFICIENT');
await ctx
.api()
.post('/api/v1/inventory/adjustments')
.auth(ctx.token, auth)
.send({
stockItemId: stock.id,
delta: -4,
reason: 'Bad correction',
idempotencyKey: randomUUID(),
})
.expect(409);
const balance = await ctx
.api()
.get(`/api/v1/inventory/stock-items/${stock.id}`)
.auth(ctx.token, auth)
.expect(200);
expect(balance.body).toMatchObject({
onHand: 10,
reserved: 7,
available: 3,
});
});
it('releases a hold idempotently and prevents committing a released hold', async () => {
const { stock } = await seedStock(ctx);
const hold = await reserve(stock.id, 4).expect(201);
const route = `/api/v1/inventory/reservations/${hold.body.id}`;
await ctx.api().get(route).auth(ctx.token, auth).expect(200);
await ctx
.api()
.post(route + '/release')
.auth(ctx.token, auth)
.expect(200);
await ctx
.api()
.post(route + '/release')
.auth(ctx.token, auth)
.expect(200);
const denied = await ctx
.api()
.post(route + '/commit')
.auth(ctx.token, auth)
.expect(409);
expect(denied.body.code).toBe('RESERVATION_CLOSED');
expect(
(
await ctx
.api()
.get(`/api/v1/inventory/stock-items/${stock.id}`)
.auth(ctx.token, auth)
).body.available,
).toBe(10);
});
it('fulfils once and reconciles on-hand balance with the ledger', async () => {
const { stock } = await seedStock(ctx);
const hold = await reserve(stock.id, 4).expect(201);
const route = `/api/v1/inventory/reservations/${hold.body.id}/commit`;
await ctx.api().post(route).auth(ctx.token, auth).expect(200);
await ctx.api().post(route).auth(ctx.token, auth).expect(200);
const ledger = await ctx
.api()
.get(`/api/v1/inventory/stock-items/${stock.id}/ledger`)
.auth(ctx.token, auth)
.expect(200);
expect(
ledger.body.reduce(
(sum: number, row: { delta: number }) => sum + row.delta,
0,
),
).toBe(6);
expect(ledger.body).toHaveLength(2);
expect(
(
await ctx
.api()
.get(`/api/v1/inventory/stock-items/${stock.id}`)
.auth(ctx.token, auth)
).body.onHand,
).toBe(6);
await expect(
ctx.executeSql(
`UPDATE stock_ledger SET delta = 99 WHERE id = '${ledger.body[0].id}'`,
),
).rejects.toThrow('append-only');
});
it('ignores expired holds without a cleanup job and rejects late commit', async () => {
const { stock } = await seedStock(ctx);
const hold = await reserve(stock.id, 10).expect(201);
await ctx.db.stockReservation.update({
where: { id: hold.body.id },
data: { expiresAt: new Date(0) },
});
expect(
(
await ctx
.api()
.get(`/api/v1/inventory/reservations/${hold.body.id}`)
.auth(ctx.token, auth)
).body.status,
).toBe('EXPIRED');
const response = await ctx
.api()
.post(`/api/v1/inventory/reservations/${hold.body.id}/commit`)
.auth(ctx.token, auth)
.expect(409);
expect(response.body.code).toBe('RESERVATION_EXPIRED');
await reserve(stock.id, 10).expect(201);
});
it('enforces scope, reservation ownership and product sellability', async () => {
const { stock, product } = await seedStock(ctx);
const outsider = await secondActor(ctx, false, [
'inventory.read',
'inventory.reserve',
]);
await ctx
.api()
.get(`/api/v1/inventory/stock-items/${stock.id}`)
.auth(outsider.token, auth)
.expect(404);
const hold = await reserve(stock.id, 1).expect(201);
const colleague = await secondActor(ctx, true, ['inventory.reserve']);
await ctx
.api()
.get(`/api/v1/inventory/reservations/${hold.body.id}`)
.auth(colleague.token, auth)
.expect(404);
await ctx
.api()
.post(`/api/v1/inventory/reservations/${hold.body.id}/release`)
.auth(colleague.token, auth)
.expect(404);
await ctx
.api()
.patch(`/api/v1/products/${product.id}/status`)
.auth(ctx.token, auth)
.send({ status: 'DRAFT' })
.expect(200);
expect((await reserve(stock.id, 1).expect(409)).body.code).toBe(
'PRODUCT_UNAVAILABLE',
);
});
it('lists scoped warehouses and stock and rejects malformed quantities', async () => {
const { stock } = await seedStock(ctx);
await ctx
.api()
.get('/api/v1/inventory/warehouses?limit=2')
.auth(ctx.token, auth)
.expect(200);
await ctx
.api()
.get('/api/v1/inventory/stock-items?limit=2')
.auth(ctx.token, auth)
.expect(200);
await reserve(stock.id, 0).expect(400);
await reserve(stock.id, 1.5).expect(400);
await ctx
.api()
.post('/api/v1/inventory/adjustments')
.auth(ctx.token, auth)
.send({
stockItemId: stock.id,
delta: 0,
reason: 'Invalid',
idempotencyKey: randomUUID(),
})
.expect(400);
});
const nativeOnly = process.env.TEST_DATABASE_URL ? it : it.skip;
nativeOnly(
'serializes competing reservations on native PostgreSQL',
async () => {
const { stock } = await seedStock(ctx, 1);
const results = await Promise.all([
reserve(stock.id, 1),
reserve(stock.id, 1),
]);
expect(results.map((row) => row.status).sort()).toEqual([201, 409]);
},
);
});

View File

@ -0,0 +1,48 @@
import {
mkdtempSync,
mkdirSync,
writeFileSync,
unlinkSync,
rmdirSync,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
describe('migration history protection', () => {
it('detects edits to a recorded migration', () => {
mkdirSync('.tmp', { recursive: true });
const root = mkdtempSync(resolve('.tmp/migration-policy-'));
const name = '20260909000000_example';
const directory = join(root, 'prisma', 'migrations', name);
mkdirSync(directory, { recursive: true });
const file = join(directory, 'migration.sql');
const manifest = join(root, 'prisma', 'migration-checksums.json');
const sql = 'SELECT 1;\n';
writeFileSync(file, sql);
writeFileSync(
manifest,
JSON.stringify({
[name]: createHash('sha256').update(sql).digest('hex'),
}),
);
const run = () =>
execFileSync(
process.execPath,
['scripts/check-migrations.mjs', '--root=' + root],
{ stdio: 'pipe' },
);
try {
expect(run().toString()).toContain('Verified 1');
writeFileSync(file, 'SELECT 2;\n');
expect(run).toThrow();
} finally {
unlinkSync(file);
unlinkSync(manifest);
rmdirSync(directory);
rmdirSync(join(root, 'prisma', 'migrations'));
rmdirSync(join(root, 'prisma'));
rmdirSync(root);
}
});
});

View File

@ -0,0 +1,71 @@
import { Logger } from '@nestjs/common';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { randomUUID } from 'node:crypto';
describe('HTTP security boundaries', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
afterEach(() => jest.restoreAllMocks());
it('bounds bodies and rejects malformed JSON with distinct codes', async () => {
const large = await ctx
.api()
.post('/api/v1/products')
.auth(ctx.token, { type: 'bearer' })
.send({ name: 'A', slug: 'a', description: 'x'.repeat(40000) })
.expect(413);
expect(large.body.code).toBe('REQUEST_TOO_LARGE');
const invalid = await ctx
.api()
.post('/api/v1/products')
.set('Content-Type', 'application/json')
.send('{"invalid":')
.expect(400);
expect(invalid.body.code).toBe('REQUEST_MALFORMED');
});
it('generates its own request IDs and ignores cookie credentials', async () => {
const response = await ctx
.api()
.get('/api/v1/addresses')
.set('X-Request-Id', 'attacker-controlled')
.set('Cookie', 'accessToken=' + ctx.token)
.expect(401);
expect(response.body.requestId).toMatch(/^[a-f0-9-]{36}$/);
expect(response.headers['x-request-id']).toBe(response.body.requestId);
expect(response.headers['cache-control']).toBe('no-store');
});
it('treats SQL-looking input as data and prevents field injection', async () => {
const name = "Robert'); DROP TABLE users;--";
await ctx
.api()
.post('/api/v1/products')
.auth(ctx.token, { type: 'bearer' })
.send({ name, slug: randomUUID() })
.expect(201);
expect(await ctx.db.user.count()).toBeGreaterThan(0);
await ctx
.api()
.get('/api/v1/products?limit=999999')
.auth(ctx.token, { type: 'bearer' })
.expect(400);
});
it('logs an unexpected database failure without exposing sensitive text', async () => {
const logger = jest.spyOn(Logger.prototype, 'error').mockImplementation();
jest
.spyOn(ctx.db.product, 'findMany')
.mockRejectedValueOnce(new Error('SELECT secret password=SECRET'));
const response = await ctx
.api()
.get('/api/v1/products')
.auth(ctx.token, { type: 'bearer' })
.expect(500);
expect(response.body.code).toBe('INTERNAL_FAILURE');
expect(JSON.stringify([response.body, logger.mock.calls])).not.toContain(
'SECRET',
);
});
});