Merge pull request 'feat/catalog-inventory' (#2) from feat/catalog-inventory into main
Reviewed-on: #2
This commit is contained in:
commit
72cb947128
|
|
@ -12,3 +12,4 @@ RECOVERY_TTL_MINUTES=15
|
||||||
# SMTP_PASSWORD=replace_me
|
# SMTP_PASSWORD=replace_me
|
||||||
# SMTP_FROM=support@example.com
|
# SMTP_FROM=support@example.com
|
||||||
# RECOVERY_URL=https://shop.example.com/reset
|
# RECOVERY_URL=https://shop.example.com/reset
|
||||||
|
DATABASE_POOL_SIZE=10
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ jobs:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- run: npm install --global pnpm@11.19.0
|
- run: npm install --global pnpm@11.19.0
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm security:audit
|
||||||
- run: pnpm db:generate
|
- run: pnpm db:generate
|
||||||
- run: node scripts/verify-migrations.mjs
|
- run: node scripts/verify-migrations.mjs
|
||||||
- run: pnpm check
|
- run: pnpm check
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,4 @@ coverage/
|
||||||
!.env.example
|
!.env.example
|
||||||
src/generated/
|
src/generated/
|
||||||
*.log
|
*.log
|
||||||
|
.tmp/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Mani Candles backend
|
# 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
|
## 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.
|
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).
|
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).
|
||||||
|
|
|
||||||
|
|
@ -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 (1–60, 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.
|
||||||
|
|
@ -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.
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 1E: verified payments/refunds, shipping/tracking, returns, notifications and operational dashboard. Test signatures, replay, partial fulfillment and reconciliation.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
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.
|
||||||
- 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 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 author: mihir <motiyanimihir@gmail.com>. Branch: feat/catalog-inventory, based on fetched main.
|
||||||
|
|
||||||
Git identity: mihir <motiyanimihir@gmail.com>. Work is based on the fetched main branch and lives on feat/identity-access.
|
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@
|
||||||
"db:validate": "prisma validate",
|
"db:validate": "prisma validate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:deploy": "prisma migrate deploy",
|
"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",
|
"start:watch": "node --watch dist/main.js",
|
||||||
"db:status": "prisma migrate status",
|
"db:status": "prisma migrate status",
|
||||||
"bootstrap:owner": "node dist/cli/bootstrap-owner.js",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.1.0",
|
"@nestjs/common": "^11.1.0",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ settings:
|
||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
'@prisma/config>deepmerge-ts': 8.0.0
|
||||||
|
prisma>mysql2: 3.23.1
|
||||||
|
'@nestjs/platform-express>multer': 2.3.0
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
|
@ -22,7 +27,7 @@ importers:
|
||||||
version: 7.10.0
|
version: 7.10.0
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^7.0.0
|
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:
|
dotenv:
|
||||||
specifier: ^17.0.0
|
specifier: ^17.0.0
|
||||||
version: 17.4.2
|
version: 17.4.2
|
||||||
|
|
@ -80,7 +85,7 @@ importers:
|
||||||
version: 3.9.6
|
version: 3.9.6
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^7.0.0
|
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:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2(supports-color@8.1.1)
|
version: 7.2.2(supports-color@8.1.1)
|
||||||
|
|
@ -1514,8 +1519,8 @@ packages:
|
||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
deepmerge-ts@7.1.5:
|
deepmerge-ts@8.0.0:
|
||||||
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
|
resolution: {integrity: sha512-ICNjaP0ML+eSdEpJYQC46XiAn/UjAdwbEl0dE8p85ZTeNDinN4Kd4+9jS4OSAuH7st6eC7rQhsqTF5zIDaUm2g==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
deepmerge@4.3.1:
|
deepmerge@4.3.1:
|
||||||
|
|
@ -2173,13 +2178,15 @@ packages:
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
multer@2.2.0:
|
multer@2.3.0:
|
||||||
resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==}
|
resolution: {integrity: sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==}
|
||||||
engines: {node: '>= 10.16.0'}
|
engines: {node: '>= 10.16.0'}
|
||||||
|
|
||||||
mysql2@3.15.3:
|
mysql2@3.23.1:
|
||||||
resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==}
|
resolution: {integrity: sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==}
|
||||||
engines: {node: '>= 8.0'}
|
engines: {node: '>= 8.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>= 8'
|
||||||
|
|
||||||
named-placeholders@1.1.6:
|
named-placeholders@1.1.6:
|
||||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||||
|
|
@ -2515,9 +2522,6 @@ packages:
|
||||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
seq-queue@0.0.5:
|
|
||||||
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
|
|
||||||
|
|
||||||
serve-static@2.2.1:
|
serve-static@2.2.1:
|
||||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
@ -2571,9 +2575,9 @@ packages:
|
||||||
sprintf-js@1.0.3:
|
sprintf-js@1.0.3:
|
||||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||||
|
|
||||||
sqlstring@2.3.3:
|
sql-escaper@1.5.1:
|
||||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||||
|
|
||||||
stack-utils@2.0.6:
|
stack-utils@2.0.6:
|
||||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
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)
|
'@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
|
cors: 2.8.6
|
||||||
express: 5.2.1(supports-color@8.1.1)
|
express: 5.2.1(supports-color@8.1.1)
|
||||||
multer: 2.2.0
|
multer: 2.3.0
|
||||||
path-to-regexp: 8.4.2
|
path-to-regexp: 8.4.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
@ -3506,17 +3510,17 @@ snapshots:
|
||||||
|
|
||||||
'@prisma/client-runtime-utils@7.10.0': {}
|
'@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:
|
dependencies:
|
||||||
'@prisma/client-runtime-utils': 7.10.0
|
'@prisma/client-runtime-utils': 7.10.0
|
||||||
optionalDependencies:
|
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
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@prisma/config@7.10.0':
|
'@prisma/config@7.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
c12: 3.3.4
|
c12: 3.3.4
|
||||||
deepmerge-ts: 7.1.5
|
deepmerge-ts: 8.0.0
|
||||||
effect: 3.20.0
|
effect: 3.20.0
|
||||||
empathic: 2.0.0
|
empathic: 2.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
@ -4294,7 +4298,7 @@ snapshots:
|
||||||
|
|
||||||
dedent@1.7.2: {}
|
dedent@1.7.2: {}
|
||||||
|
|
||||||
deepmerge-ts@7.1.5: {}
|
deepmerge-ts@8.0.0: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
|
|
@ -5123,15 +5127,16 @@ snapshots:
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
multer@2.2.0:
|
multer@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
append-field: 1.0.0
|
append-field: 1.0.0
|
||||||
busboy: 1.6.0
|
busboy: 1.6.0
|
||||||
concat-stream: 2.0.0
|
concat-stream: 2.0.0
|
||||||
type-is: 1.6.18
|
type-is: 1.6.18
|
||||||
|
|
||||||
mysql2@3.15.3:
|
mysql2@3.23.1(@types/node@24.13.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@types/node': 24.13.3
|
||||||
aws-ssl-profiles: 1.1.2
|
aws-ssl-profiles: 1.1.2
|
||||||
denque: 2.1.0
|
denque: 2.1.0
|
||||||
generate-function: 2.3.1
|
generate-function: 2.3.1
|
||||||
|
|
@ -5139,8 +5144,7 @@ snapshots:
|
||||||
long: 5.3.2
|
long: 5.3.2
|
||||||
lru.min: 1.1.5
|
lru.min: 1.1.5
|
||||||
named-placeholders: 1.1.6
|
named-placeholders: 1.1.6
|
||||||
seq-queue: 0.0.5
|
sql-escaper: 1.5.1
|
||||||
sqlstring: 2.3.3
|
|
||||||
|
|
||||||
named-placeholders@1.1.6:
|
named-placeholders@1.1.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -5309,17 +5313,18 @@ snapshots:
|
||||||
'@jest/schemas': 30.5.0
|
'@jest/schemas': 30.5.0
|
||||||
ansi-styles: 5.2.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:
|
dependencies:
|
||||||
'@prisma/config': 7.10.0
|
'@prisma/config': 7.10.0
|
||||||
'@prisma/dev': 0.24.17(typescript@5.9.3)
|
'@prisma/dev': 0.24.17(typescript@5.9.3)
|
||||||
'@prisma/engines': 7.10.0
|
'@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)
|
'@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
|
postgres: 3.4.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
- magicast
|
- magicast
|
||||||
|
|
@ -5443,8 +5448,6 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
seq-queue@0.0.5: {}
|
|
||||||
|
|
||||||
serve-static@2.2.1(supports-color@8.1.1):
|
serve-static@2.2.1(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
|
|
@ -5502,7 +5505,7 @@ snapshots:
|
||||||
|
|
||||||
sprintf-js@1.0.3: {}
|
sprintf-js@1.0.3: {}
|
||||||
|
|
||||||
sqlstring@2.3.3: {}
|
sql-escaper@1.5.1: {}
|
||||||
|
|
||||||
stack-utils@2.0.6:
|
stack-utils@2.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,7 @@ allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
prisma: true
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
|
overrides:
|
||||||
|
'@prisma/config>deepmerge-ts': 8.0.0
|
||||||
|
'prisma>mysql2': 3.23.1
|
||||||
|
'@nestjs/platform-express>multer': 2.3.0
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'dotenv/config';
|
||||||
import { defineConfig } from 'prisma/config';
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: 'prisma/schema.prisma',
|
schema: 'prisma',
|
||||||
migrations: { path: 'prisma/migrations' },
|
migrations: { path: 'prisma/migrations' },
|
||||||
datasource: { url: process.env.DATABASE_URL },
|
datasource: { url: process.env.DATABASE_URL },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -19,6 +19,9 @@ model Organization {
|
||||||
users User[]
|
users User[]
|
||||||
roles Role[]
|
roles Role[]
|
||||||
auditEvents AuditEvent[]
|
auditEvents AuditEvent[]
|
||||||
|
products Product[]
|
||||||
|
catalogGroups CatalogGroup[]
|
||||||
|
warehouses Warehouse[]
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
model User {
|
model User {
|
||||||
|
|
@ -35,6 +38,9 @@ model User {
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
recoveryTokens RecoveryToken[]
|
recoveryTokens RecoveryToken[]
|
||||||
|
addresses Address[]
|
||||||
|
stockEntries StockLedger[]
|
||||||
|
stockReservations StockReservation[]
|
||||||
@@unique([organizationId, email])
|
@@unique([organizationId, email])
|
||||||
@@unique([id, organizationId])
|
@@unique([id, organizationId])
|
||||||
@@index([organizationId, createdAt, id])
|
@@index([organizationId, createdAt, id])
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
@ -10,7 +10,7 @@ const commands = [
|
||||||
'diff',
|
'diff',
|
||||||
'--from-config-datasource',
|
'--from-config-datasource',
|
||||||
'--to-schema',
|
'--to-schema',
|
||||||
'prisma/schema.prisma',
|
'prisma',
|
||||||
'--exit-code',
|
'--exit-code',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 { Module } from '@nestjs/common';
|
||||||
import { EnvironmentModule } from './config/environment.module';
|
import { EnvironmentModule } from './config/environment.module';
|
||||||
import { IdentityModule } from './identity/identity.module';
|
import { IdentityModule } from './identity/identity.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
@Module({ imports: [EnvironmentModule, HealthModule, IdentityModule] })
|
@Module({
|
||||||
|
imports: [
|
||||||
|
EnvironmentModule,
|
||||||
|
HealthModule,
|
||||||
|
IdentityModule,
|
||||||
|
CatalogModule,
|
||||||
|
AddressesModule,
|
||||||
|
InventoryModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -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 } } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import { BadRequestException, PipeTransform } from '@nestjs/common';
|
import type { PipeTransform } from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { AppError } from './errors/app-error';
|
||||||
|
|
||||||
export class SchemaPipe<T> implements PipeTransform<unknown, T> {
|
export class SchemaPipe<T> implements PipeTransform<unknown, T> {
|
||||||
constructor(private readonly schema: z.ZodType<T>) {}
|
constructor(private readonly schema: z.ZodType<T>) {}
|
||||||
transform(value: unknown): T {
|
transform(value: unknown): T {
|
||||||
const result = this.schema.safeParse(value);
|
const result = this.schema.safeParse(value);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new BadRequestException({
|
throw new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', [
|
||||||
message: 'Invalid request',
|
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
|
||||||
fields: [
|
]);
|
||||||
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const schema = z
|
||||||
.enum(['development', 'test', 'production'])
|
.enum(['development', 'test', 'production'])
|
||||||
.default('development'),
|
.default('development'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
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)),
|
DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
|
||||||
CORS_ORIGINS: z
|
CORS_ORIGINS: z
|
||||||
.string()
|
.string()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import type { INestApplication } from '@nestjs/common';
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import type { Environment } from './config/environment';
|
import type { Environment } from './config/environment';
|
||||||
|
import { ApiExceptionFilter } from './common/errors/api-exception.filter';
|
||||||
|
|
||||||
export function configureApp(
|
export function configureApp(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
|
|
@ -8,6 +12,19 @@ export function configureApp(
|
||||||
): void {
|
): void {
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
app.use(helmet());
|
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.enableCors({ origin: environment.CORS_ORIGINS, credentials: true });
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export class DatabaseService
|
||||||
connectionString: environment.DATABASE_URL,
|
connectionString: environment.DATABASE_URL,
|
||||||
connectionTimeoutMillis: 3000,
|
connectionTimeoutMillis: 3000,
|
||||||
query_timeout: 3000,
|
query_timeout: 3000,
|
||||||
max: 10,
|
max: environment.DATABASE_POOL_SIZE,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
import { DatabaseService } from '../database/database.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -13,7 +14,7 @@ export class HealthService {
|
||||||
try {
|
try {
|
||||||
await this.database.ping();
|
await this.database.ping();
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException('Service is not ready');
|
throw new AppError('DATABASE_NOT_READY');
|
||||||
}
|
}
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import {
|
import { RateLimitService } from './rate-limit.service';
|
||||||
CanActivate,
|
import { AppError } from '../common/errors/app-error';
|
||||||
ExecutionContext,
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { SessionStore } from './session.store';
|
import { SessionStore } from './session.store';
|
||||||
import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator';
|
import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator';
|
||||||
|
|
@ -15,6 +11,7 @@ export class AccessGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
private readonly sessions: SessionStore,
|
private readonly sessions: SessionStore,
|
||||||
|
private readonly limits: RateLimitService,
|
||||||
) {}
|
) {}
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const targets = [context.getHandler(), context.getClass()];
|
const targets = [context.getHandler(), context.getClass()];
|
||||||
|
|
@ -24,14 +21,20 @@ export class AccessGuard implements CanActivate {
|
||||||
const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(
|
const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(
|
||||||
request.headers.authorization ?? '',
|
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]);
|
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>(
|
const permission = this.reflector.getAllAndOverride<string>(
|
||||||
REQUIRED_PERMISSION,
|
REQUIRED_PERMISSION,
|
||||||
targets,
|
targets,
|
||||||
);
|
);
|
||||||
if (permission && !request.principal.permissions.includes(permission))
|
if (permission && !request.principal.permissions.includes(permission))
|
||||||
throw new ForbiddenException();
|
throw new AppError('ACCESS_DENIED');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import {
|
import { Injectable } from '@nestjs/common';
|
||||||
ConflictException,
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { Prisma } from '../generated/prisma/client';
|
import { Prisma } from '../generated/prisma/client';
|
||||||
import { readPrincipal } from './session.store';
|
import { readPrincipal } from './session.store';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
import type { Principal } from './identity.types';
|
import type { Principal } from './identity.types';
|
||||||
import type { Permission } from './permissions';
|
import type { Permission } from './permissions';
|
||||||
|
|
||||||
|
|
@ -14,20 +11,24 @@ export class AccessStore {
|
||||||
constructor(private readonly db: DatabaseService) {}
|
constructor(private readonly db: DatabaseService) {}
|
||||||
async mutate<T>(
|
async mutate<T>(
|
||||||
actor: Principal,
|
actor: Principal,
|
||||||
permission: Permission,
|
permission: Permission | null,
|
||||||
work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>,
|
work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>,
|
||||||
|
lockOrganization = true,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
return await this.db.$transaction(async (tx) => {
|
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`;
|
await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`;
|
||||||
|
}
|
||||||
const current = await readPrincipal(tx, actor.sessionId);
|
const current = await readPrincipal(tx, actor.sessionId);
|
||||||
if (
|
if (
|
||||||
current.organizationId !== actor.organizationId ||
|
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);
|
return work(tx, current);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -35,9 +36,7 @@ export class AccessStore {
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
error.code === 'P2002'
|
error.code === 'P2002'
|
||||||
) {
|
) {
|
||||||
throw new ConflictException(
|
throw new AppError('RECORD_CONFLICT');
|
||||||
'A record with these details already exists',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ENVIRONMENT } from '../config/environment.module';
|
import { ENVIRONMENT } from '../config/environment.module';
|
||||||
import type { Environment } from '../config/environment';
|
import type { Environment } from '../config/environment';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
import { AuthStore } from './auth.store';
|
import { AuthStore } from './auth.store';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
import { RateLimitService } from './rate-limit.service';
|
import { RateLimitService } from './rate-limit.service';
|
||||||
|
|
@ -22,12 +23,18 @@ export class AuthService {
|
||||||
900,
|
900,
|
||||||
);
|
);
|
||||||
const user = await this.store.findUser(input.organizationId, input.email);
|
const user = await this.store.findUser(input.organizationId, input.email);
|
||||||
const valid = user
|
let valid = false;
|
||||||
? await this.passwords.verify(input.password, user.passwordHash)
|
if (user)
|
||||||
: (await this.passwords.dummyVerify(input.password), false);
|
valid = await this.passwords.verify(input.password, user.passwordHash);
|
||||||
|
else await this.passwords.dummyVerify(input.password);
|
||||||
if (!valid || !user || user.status !== 'ACTIVE') {
|
if (!valid || !user || user.status !== 'ACTIVE') {
|
||||||
if (user) await this.store.failedLogin(user.organizationId, user.id);
|
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 { token, tokenHash } = issueToken();
|
||||||
const expiresAt = new Date(
|
const expiresAt = new Date(
|
||||||
|
|
|
||||||
|
|
@ -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 { DatabaseService } from '../database/database.service';
|
||||||
import { recordAudit } from './audit';
|
import { recordAudit } from './audit';
|
||||||
import type { Principal } from './identity.types';
|
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`;
|
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`;
|
||||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
if (user.status !== 'ACTIVE' || user.passwordHash !== expectedHash)
|
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 tx.session.create({ data: { userId, tokenHash, expiresAt } });
|
||||||
await recordAudit(
|
await recordAudit(
|
||||||
tx,
|
tx,
|
||||||
|
|
|
||||||
|
|
@ -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 { DatabaseService } from '../database/database.service';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
import { PERMISSIONS } from './permissions';
|
import { PERMISSIONS } from './permissions';
|
||||||
|
|
@ -17,7 +18,7 @@ export class BootstrapService {
|
||||||
// One-time installation bootstrap, serialized across processes.
|
// One-time installation bootstrap, serialized across processes.
|
||||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`;
|
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`;
|
||||||
if (await tx.user.count({ where: { isOwner: true } }))
|
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({
|
const organization = await tx.organization.create({
|
||||||
data: { name: organizationName },
|
data: { name: organizationName },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,6 @@ import { UsersController } from './users.controller';
|
||||||
UserStore,
|
UserStore,
|
||||||
{ provide: APP_GUARD, useClass: AccessGuard },
|
{ provide: APP_GUARD, useClass: AccessGuard },
|
||||||
],
|
],
|
||||||
exports: [BootstrapService],
|
exports: [BootstrapService, AccessStore, RateLimitService],
|
||||||
})
|
})
|
||||||
export class IdentityModule {}
|
export class IdentityModule {}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,13 @@ export const PERMISSIONS = [
|
||||||
'roles.read',
|
'roles.read',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
'audit.read',
|
'audit.read',
|
||||||
|
'catalog.read',
|
||||||
|
'catalog.manage',
|
||||||
|
'catalog.publish',
|
||||||
|
'inventory.read',
|
||||||
|
'inventory.manage',
|
||||||
|
'inventory.adjust',
|
||||||
|
'inventory.reserve',
|
||||||
|
'inventory.commit',
|
||||||
] as const;
|
] as const;
|
||||||
export type Permission = (typeof PERMISSIONS)[number];
|
export type Permission = (typeof PERMISSIONS)[number];
|
||||||
|
|
|
||||||
|
|
@ -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 { DatabaseService } from '../database/database.service';
|
||||||
import { hashToken } from './tokens';
|
import { hashToken } from './tokens';
|
||||||
|
|
||||||
|
|
@ -7,19 +8,23 @@ export class RateLimitService {
|
||||||
constructor(private readonly db: DatabaseService) {}
|
constructor(private readonly db: DatabaseService) {}
|
||||||
async consume(key: string, limit: number, seconds: number): Promise<void> {
|
async consume(key: string, limit: number, seconds: number): Promise<void> {
|
||||||
const digest = hashToken(key);
|
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)
|
INSERT INTO rate_limits (key, hits, expires_at)
|
||||||
VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second')
|
VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second')
|
||||||
ON CONFLICT (key) DO UPDATE SET
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
hits = CASE WHEN rate_limits.expires_at <= NOW() THEN 1 ELSE rate_limits.hits + 1 END,
|
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()
|
expires_at = CASE WHEN rate_limits.expires_at <= NOW()
|
||||||
THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END
|
THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END
|
||||||
RETURNING hits
|
RETURNING hits, expires_at AS "expiresAt"
|
||||||
`;
|
`;
|
||||||
if (row.hits > limit)
|
if (row.hits > limit)
|
||||||
throw new HttpException(
|
throw new AppError(
|
||||||
'Too many requests',
|
'RATE_LIMITED',
|
||||||
HttpStatus.TOO_MANY_REQUESTS,
|
key.split(':')[0],
|
||||||
|
undefined,
|
||||||
|
Math.max(1, Math.ceil((row.expiresAt.getTime() - Date.now()) / 1000)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import {
|
import { AppError } from '../common/errors/app-error';
|
||||||
Inject,
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { createTransport } from 'nodemailer';
|
import { createTransport } from 'nodemailer';
|
||||||
import { ENVIRONMENT } from '../config/environment.module';
|
import { ENVIRONMENT } from '../config/environment.module';
|
||||||
import type { Environment } from '../config/environment';
|
import type { Environment } from '../config/environment';
|
||||||
|
|
@ -11,10 +8,7 @@ import type { Environment } from '../config/environment';
|
||||||
export class RecoveryMailer {
|
export class RecoveryMailer {
|
||||||
constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {}
|
constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {}
|
||||||
assertConfigured(): void {
|
assertConfigured(): void {
|
||||||
if (!this.env.SMTP_HOST)
|
if (!this.env.SMTP_HOST) throw new AppError('RECOVERY_UNAVAILABLE');
|
||||||
throw new ServiceUnavailableException(
|
|
||||||
'Password recovery is not configured',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
async send(email: string, token: string): Promise<void> {
|
async send(email: string, token: string): Promise<void> {
|
||||||
this.assertConfigured();
|
this.assertConfigured();
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,12 @@ export class RecoveryService {
|
||||||
} catch {
|
} catch {
|
||||||
await this.store.discard(tokenHash);
|
await this.store.discard(tokenHash);
|
||||||
// SMTP errors may contain credentials and addresses; do not log the raw error.
|
// 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> {
|
async reset(token: string, password: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -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 { DatabaseService } from '../database/database.service';
|
||||||
import { recordAudit } from './audit';
|
import { recordAudit } from './audit';
|
||||||
|
|
||||||
|
|
@ -32,8 +33,7 @@ export class RecoveryStore {
|
||||||
async reset(tokenHash: string, passwordHash: string) {
|
async reset(tokenHash: string, passwordHash: string) {
|
||||||
await this.db.$transaction(async (tx) => {
|
await this.db.$transaction(async (tx) => {
|
||||||
const token = await tx.recoveryToken.findUnique({ where: { tokenHash } });
|
const token = await tx.recoveryToken.findUnique({ where: { tokenHash } });
|
||||||
if (!token)
|
if (!token) throw new AppError('RECOVERY_INVALID');
|
||||||
throw new BadRequestException('Invalid or expired recovery token');
|
|
||||||
await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`;
|
await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`;
|
||||||
const user = await tx.user.findUniqueOrThrow({
|
const user = await tx.user.findUniqueOrThrow({
|
||||||
where: { id: token.userId },
|
where: { id: token.userId },
|
||||||
|
|
@ -42,7 +42,7 @@ export class RecoveryStore {
|
||||||
where: { id: token.id, expiresAt: { gt: new Date() } },
|
where: { id: token.id, expiresAt: { gt: new Date() } },
|
||||||
});
|
});
|
||||||
if (consumed.count !== 1 || user.status !== 'ACTIVE') {
|
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.user.update({ where: { id: user.id }, data: { passwordHash } });
|
||||||
await tx.recoveryToken.deleteMany({ where: { userId: user.id } });
|
await tx.recoveryToken.deleteMany({ where: { userId: user.id } });
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import {
|
import { AppError } from '../common/errors/app-error';
|
||||||
ForbiddenException,
|
import { Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { AccessStore } from './access.store';
|
import { AccessStore } from './access.store';
|
||||||
import { recordAudit } from './audit';
|
import { recordAudit } from './audit';
|
||||||
|
|
@ -13,7 +10,7 @@ function ensureGrantable(actor: Principal, permissions: string[]) {
|
||||||
if (
|
if (
|
||||||
permissions.some((permission) => !actor.permissions.includes(permission))
|
permissions.some((permission) => !actor.permissions.includes(permission))
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Cannot grant permissions you do not hold');
|
throw new AppError('ROLE_GRANT_DENIED');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -37,9 +34,8 @@ export class RoleStore {
|
||||||
const role = await tx.role.findFirst({
|
const role = await tx.role.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId },
|
where: { id, organizationId: actor.organizationId },
|
||||||
});
|
});
|
||||||
if (!role) throw new NotFoundException();
|
if (!role) throw new AppError('ROLE_NOT_FOUND');
|
||||||
if (role.isSystem)
|
if (role.isSystem) throw new AppError('ROLE_IMMUTABLE');
|
||||||
throw new ForbiddenException('System role is immutable');
|
|
||||||
ensureGrantable(current, role.permissions);
|
ensureGrantable(current, role.permissions);
|
||||||
}
|
}
|
||||||
const role = id
|
const role = id
|
||||||
|
|
@ -66,9 +62,9 @@ export class RoleStore {
|
||||||
where: { id: userId, organizationId: actor.organizationId },
|
where: { id: userId, organizationId: actor.organizationId },
|
||||||
include: { roles: { include: { role: true } } },
|
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)
|
if (user.isOwner || user.id === current.userId)
|
||||||
throw new ForbiddenException('Cannot change these role assignments');
|
throw new AppError('ROLE_ASSIGNMENT_DENIED');
|
||||||
ensureGrantable(
|
ensureGrantable(
|
||||||
current,
|
current,
|
||||||
user.roles.flatMap((assignment) => assignment.role.permissions),
|
user.roles.flatMap((assignment) => assignment.role.permissions),
|
||||||
|
|
@ -76,9 +72,10 @@ export class RoleStore {
|
||||||
const roles = await tx.role.findMany({
|
const roles = await tx.role.findMany({
|
||||||
where: { id: { in: roleIds }, organizationId: actor.organizationId },
|
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))
|
if (roles.some((role) => role.isSystem))
|
||||||
throw new ForbiddenException('System role cannot be assigned');
|
throw new AppError('ROLE_SYSTEM_DENIED');
|
||||||
ensureGrantable(
|
ensureGrantable(
|
||||||
current,
|
current,
|
||||||
roles.flatMap((role) => role.permissions),
|
roles.flatMap((role) => role.permissions),
|
||||||
|
|
|
||||||
|
|
@ -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 { DatabaseService } from '../database/database.service';
|
||||||
import type { Prisma } from '../generated/prisma/client';
|
import type { Prisma } from '../generated/prisma/client';
|
||||||
import { hashToken } from './tokens';
|
import { hashToken } from './tokens';
|
||||||
|
|
@ -18,7 +19,7 @@ export async function readPrincipal(
|
||||||
session.expiresAt <= new Date() ||
|
session.expiresAt <= new Date() ||
|
||||||
session.user.status !== 'ACTIVE'
|
session.user.status !== 'ACTIVE'
|
||||||
) {
|
) {
|
||||||
throw new UnauthorizedException();
|
throw new AppError('SESSION_INVALID');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
|
|
@ -36,7 +37,7 @@ export class SessionStore {
|
||||||
const session = await this.db.session.findUnique({
|
const session = await this.db.session.findUnique({
|
||||||
where: { tokenHash: hashToken(token) },
|
where: { tokenHash: hashToken(token) },
|
||||||
});
|
});
|
||||||
if (!session) throw new UnauthorizedException();
|
if (!session) throw new AppError('SESSION_INVALID');
|
||||||
return readPrincipal(this.db, session.id);
|
return readPrincipal(this.db, session.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import {
|
import { AppError } from '../common/errors/app-error';
|
||||||
ForbiddenException,
|
import { Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { AccessStore } from './access.store';
|
import { AccessStore } from './access.store';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
|
|
@ -66,9 +63,9 @@ export class UserStore {
|
||||||
const user = await tx.user.findFirst({
|
const user = await tx.user.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId },
|
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)
|
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({
|
const updated = await tx.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status },
|
data: { status },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -77,7 +77,7 @@ describe('administration use-case policy', () => {
|
||||||
it('hides users outside the organization scope', async () => {
|
it('hides users outside the organization scope', async () => {
|
||||||
tx.user.findFirst.mockResolvedValue(null);
|
tx.user.findFirst.mockResolvedValue(null);
|
||||||
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
|
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
|
||||||
'Not Found',
|
/not found/i,
|
||||||
);
|
);
|
||||||
expect(tx.user.update).not.toHaveBeenCalled();
|
expect(tx.user.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -105,7 +105,7 @@ describe('administration use-case policy', () => {
|
||||||
tx.role.findFirst.mockResolvedValue(null);
|
tx.role.findFirst.mockResolvedValue(null);
|
||||||
await expect(
|
await expect(
|
||||||
roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'),
|
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 () => {
|
it('does not remove a target user permissions the actor cannot grant', async () => {
|
||||||
tx.user.findFirst.mockResolvedValue({
|
tx.user.findFirst.mockResolvedValue({
|
||||||
|
|
@ -121,7 +121,7 @@ describe('administration use-case policy', () => {
|
||||||
it('hides missing assignment targets', async () => {
|
it('hides missing assignment targets', async () => {
|
||||||
tx.user.findFirst.mockResolvedValue(null);
|
tx.user.findFirst.mockResolvedValue(null);
|
||||||
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
|
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
|
||||||
'Not Found',
|
/not found/i,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ export async function identityApp() {
|
||||||
const env = parseEnvironment({
|
const env = parseEnvironment({
|
||||||
DATABASE_URL: database.connectionUrl,
|
DATABASE_URL: database.connectionUrl,
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
DATABASE_POOL_SIZE: process.env.TEST_DATABASE_URL ? '10' : '1',
|
||||||
});
|
});
|
||||||
const mailer = {
|
const mailer = {
|
||||||
assertConfigured: jest.fn(),
|
assertConfigured: jest.fn(),
|
||||||
|
|
@ -28,6 +29,7 @@ export async function identityApp() {
|
||||||
.useValue(mailer)
|
.useValue(mailer)
|
||||||
.compile();
|
.compile();
|
||||||
const app = module.createNestApplication();
|
const app = module.createNestApplication();
|
||||||
|
app.useLogger(false);
|
||||||
configureApp(app, env);
|
configureApp(app, env);
|
||||||
await app.init();
|
await app.init();
|
||||||
const db = app.get(DatabaseService);
|
const db = app.get(DatabaseService);
|
||||||
|
|
@ -51,6 +53,7 @@ export async function identityApp() {
|
||||||
app,
|
app,
|
||||||
db,
|
db,
|
||||||
|
|
||||||
|
executeSql: database.execute,
|
||||||
owner,
|
owner,
|
||||||
token,
|
token,
|
||||||
api,
|
api,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ describe('authentication with migrated PostgreSQL engine', () => {
|
||||||
const missing = await ctx.login('missing@example.com');
|
const missing = await ctx.login('missing@example.com');
|
||||||
const wrong = await ctx.login('owner@example.com', 'wrong password');
|
const wrong = await ctx.login('owner@example.com', 'wrong password');
|
||||||
expect(missing.status).toBe(401);
|
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 () => {
|
it('validates payloads without echoing secrets and rejects mass assignment', async () => {
|
||||||
const response = await ctx
|
const response = await ctx
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue