Compare commits
5 Commits
feat/comme
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
d22aa78b6d | |
|
|
22a26c2e25 | |
|
|
46dfae07eb | |
|
|
2a85081c7d | |
|
|
3da6f4e469 |
|
|
@ -13,3 +13,6 @@ RECOVERY_TTL_MINUTES=15
|
||||||
# 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
|
DATABASE_POOL_SIZE=10
|
||||||
|
|
||||||
|
# Optional; defaults to true only in development.
|
||||||
|
# SWAGGER_ENABLED=true
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,8 @@ Identity: see [API contract](docs/identity-api.md) and [setup/release guide](doc
|
||||||
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).
|
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).
|
||||||
Phase 1D adds versioned carts, coupons, atomic checkout and private order snapshots. See [checkout API and pricing boundary](docs/checkout-api.md). Payment remains disabled until tax, shipping and payment rules are finalized.
|
Phase 1D adds versioned carts, coupons, atomic checkout and private order snapshots. See [checkout API and pricing boundary](docs/checkout-api.md). Payment remains disabled until tax, shipping and payment rules are finalized.
|
||||||
Phase 1E provides a [provider-independent blueprint and test matrix](docs/phase1e-blueprint.md), configurable pricing snapshots and an [operational outbox/API](docs/operations-api.md). No real gateway or message delivery is enabled.
|
Phase 1E provides a [provider-independent blueprint and test matrix](docs/phase1e-blueprint.md), configurable pricing snapshots and an [operational outbox/API](docs/operations-api.md). No real gateway or message delivery is enabled.
|
||||||
|
Phase 2A adds organization-scoped [supplier and material master data](docs/procurement-api.md). Purchase orders, receipts, QC and production remain later milestones.
|
||||||
|
|
||||||
|
### Swagger UI
|
||||||
|
|
||||||
|
Open http://localhost:3000/api/docs in development to browse and test the API. See [API explorer](docs/swagger.md) for authentication and configuration.
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,4 @@ Production uses `pnpm db:deploy`, then `pnpm db:status`. Never use db push in pr
|
||||||
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.
|
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.
|
||||||
Phase 1D appends checkout tables, reservation ownership, immutable snapshot guards and deferred order/line reconciliation. Earlier migrations are unchanged.
|
Phase 1D appends checkout tables, reservation ownership, immutable snapshot guards and deferred order/line reconciliation. Earlier migrations are unchanged.
|
||||||
Phase 1E adds pricing policies, immutable final-price snapshots and commerce events with leased delivery state in two further timestamped migrations. All eleven preceding migrations remain unchanged.
|
Phase 1E adds pricing policies, immutable final-price snapshots and commerce events with leased delivery state in two further timestamped migrations. All eleven preceding migrations remain unchanged.
|
||||||
|
Phase 2A appends supplier, material and supplier-material compatibility tables. It grants procurement permissions to existing system roles; custom roles must be updated through the RBAC API.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Phase 2A: suppliers and materials
|
||||||
|
|
||||||
|
This phase introduces private, organization-scoped supplier and material master data. It does not create purchase orders, receipts, invoices, stock movements, production batches, or documents.
|
||||||
|
|
||||||
|
| Method | Route | Permission | Purpose |
|
||||||
|
| -------- | -------------------------------------------- | ------------------ | --------------------------------------------------------------- |
|
||||||
|
| GET | /suppliers | procurement.read | Page through suppliers; filter with `active` and `search` |
|
||||||
|
| POST/PUT | /suppliers, /suppliers/:id | procurement.manage | Create or replace supplier records |
|
||||||
|
| GET | /materials | procurement.read | Page through materials; filter with `active` and `search` |
|
||||||
|
| POST/PUT | /materials, /materials/:id | procurement.manage | Create or replace materials |
|
||||||
|
| GET | /suppliers/:supplierId/materials | procurement.read | View the supplier's compatible materials |
|
||||||
|
| PUT | /suppliers/:supplierId/materials/:materialId | procurement.manage | Create or replace compatibility, commercial lead time and quote |
|
||||||
|
|
||||||
|
Supplier and material codes are uppercase, organization-unique identifiers. Materials use a fixed kind and unit to avoid ambiguous procurement and BOM quantities. Compatibility records hold a supplier SKU, lead time, minimum quantity, price, currency, and active status. A future purchase order must snapshot these values rather than rely on a later edit.
|
||||||
|
|
||||||
|
All endpoints require a session and enforce a permission again inside write transactions. Missing suppliers and materials produce distinct scoped errors. Create, update, and compatibility actions are audit-recorded without arbitrary payloads. The migration is append-only and grants the new permissions only to existing system roles; custom roles require an explicit update.
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# API explorer
|
||||||
|
|
||||||
|
Start the backend with the existing database configuration (`pnpm build`, then `pnpm start`). In development, open **http://localhost:3000/api/docs**. The machine-readable OpenAPI specification is at **/api/docs-json**. If PORT differs, use that port.
|
||||||
|
|
||||||
|
1. Bootstrap an owner using the setup instructions in README, or use an existing account.
|
||||||
|
2. Expand Auth and execute `POST /api/v1/auth/login` with your organization UUID, email and password.
|
||||||
|
3. Copy `accessToken` from the successful response. Click **Authorize**, paste the token without a `Bearer` prefix and confirm.
|
||||||
|
4. Select an endpoint, click **Try it out**, fill its parameters/body and execute. Required permissions appear in the endpoint description. Requests use your real account permissions and can change data.
|
||||||
|
5. Log out through the API when finished and clear authorization in the UI.
|
||||||
|
|
||||||
|
The UI groups all registered controller routes. Request bodies, required fields, query defaults and constraints are derived from the same Zod schemas used by validation. Custom cross-field refinements and business rules still apply on the server. Successful response bodies are not yet exhaustively modeled; inspect actual responses. Errors share a documented envelope with distinct codes and a request ID for log correlation.
|
||||||
|
|
||||||
|
`SWAGGER_ENABLED=true` explicitly enables documentation; `false` disables it. If omitted, it is enabled only in development. Documentation itself does not require a session, so enable it on a deployed environment only when its API inventory should be visible there. Protected API operations still require authentication and permissions. Tokens are not persisted by Swagger across reloads. Assets are served locally and external schema validation is disabled.
|
||||||
|
|
||||||
|
Payment and shipping remain test blueprints. No gateway credentials or real integration are required by Swagger. No database schema changes or migrations are needed for this feature.
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"@nestjs/common": "^11.1.0",
|
"@nestjs/common": "^11.1.0",
|
||||||
"@nestjs/core": "^11.1.0",
|
"@nestjs/core": "^11.1.0",
|
||||||
"@nestjs/platform-express": "^11.1.0",
|
"@nestjs/platform-express": "^11.1.0",
|
||||||
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@prisma/adapter-pg": "^7.0.0",
|
"@prisma/adapter-pg": "^7.0.0",
|
||||||
"@prisma/client": "^7.0.0",
|
"@prisma/client": "^7.0.0",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ importers:
|
||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)
|
version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)
|
||||||
|
'@nestjs/swagger':
|
||||||
|
specifier: ^11.4.7
|
||||||
|
version: 11.4.7(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(reflect-metadata@0.2.2)
|
||||||
'@prisma/adapter-pg':
|
'@prisma/adapter-pg':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.10.0
|
version: 7.10.0
|
||||||
|
|
@ -610,6 +613,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0':
|
||||||
|
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.2.3':
|
'@napi-rs/wasm-runtime@1.2.3':
|
||||||
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
|
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||||
|
|
@ -648,12 +654,42 @@ packages:
|
||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/mapped-types@2.1.1':
|
||||||
|
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
class-transformer: ^0.4.0 || ^0.5.0
|
||||||
|
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
|
||||||
|
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
class-transformer:
|
||||||
|
optional: true
|
||||||
|
class-validator:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@nestjs/platform-express@11.2.3':
|
'@nestjs/platform-express@11.2.3':
|
||||||
resolution: {integrity: sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==}
|
resolution: {integrity: sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@nestjs/common': ^11.0.0
|
'@nestjs/common': ^11.0.0
|
||||||
'@nestjs/core': ^11.0.0
|
'@nestjs/core': ^11.0.0
|
||||||
|
|
||||||
|
'@nestjs/swagger@11.4.7':
|
||||||
|
resolution: {integrity: sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||||
|
'@nestjs/common': ^11.0.1
|
||||||
|
'@nestjs/core': ^11.0.1
|
||||||
|
class-transformer: '*'
|
||||||
|
class-validator: '*'
|
||||||
|
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@fastify/static':
|
||||||
|
optional: true
|
||||||
|
class-transformer:
|
||||||
|
optional: true
|
||||||
|
class-validator:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@nestjs/testing@11.2.3':
|
'@nestjs/testing@11.2.3':
|
||||||
resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==}
|
resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -901,6 +937,9 @@ packages:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@scarf/scarf@1.4.0':
|
||||||
|
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||||
|
|
||||||
'@sinclair/typebox@0.34.52':
|
'@sinclair/typebox@0.34.52':
|
||||||
resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==}
|
resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==}
|
||||||
|
|
||||||
|
|
@ -1245,6 +1284,9 @@ packages:
|
||||||
argparse@1.0.10:
|
argparse@1.0.10:
|
||||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||||
|
|
||||||
|
argparse@2.0.1:
|
||||||
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
asap@2.0.6:
|
asap@2.0.6:
|
||||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||||
|
|
||||||
|
|
@ -2051,6 +2093,10 @@ packages:
|
||||||
resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==}
|
resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
js-yaml@5.3.0:
|
||||||
|
resolution: {integrity: sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
jsesc@3.1.0:
|
jsesc@3.1.0:
|
||||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -2649,6 +2695,9 @@ packages:
|
||||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
swagger-ui-dist@5.32.13:
|
||||||
|
resolution: {integrity: sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==}
|
||||||
|
|
||||||
synckit@0.11.13:
|
synckit@0.11.13:
|
||||||
resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
|
resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
|
@ -3380,6 +3429,8 @@ snapshots:
|
||||||
|
|
||||||
'@lukeed/csprng@1.1.0': {}
|
'@lukeed/csprng@1.1.0': {}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0': {}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
|
|
@ -3412,6 +3463,11 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)
|
'@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)
|
||||||
|
|
||||||
|
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
'@nestjs/platform-express@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)':
|
'@nestjs/platform-express@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
|
|
@ -3424,6 +3480,18 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@nestjs/swagger@11.4.7(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(reflect-metadata@0.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/tsdoc': 0.16.0
|
||||||
|
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
|
'@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/mapped-types': 2.1.1(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)
|
||||||
|
js-yaml: 5.3.0
|
||||||
|
lodash: 4.18.1
|
||||||
|
path-to-regexp: 8.4.2
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
swagger-ui-dist: 5.32.13
|
||||||
|
|
||||||
'@nestjs/testing@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)':
|
'@nestjs/testing@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
|
|
@ -3659,6 +3727,8 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
|
|
||||||
|
'@scarf/scarf@1.4.0': {}
|
||||||
|
|
||||||
'@sinclair/typebox@0.34.52': {}
|
'@sinclair/typebox@0.34.52': {}
|
||||||
|
|
||||||
'@sinonjs/commons@3.0.1':
|
'@sinonjs/commons@3.0.1':
|
||||||
|
|
@ -4022,6 +4092,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
sprintf-js: 1.0.3
|
sprintf-js: 1.0.3
|
||||||
|
|
||||||
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
asap@2.0.6: {}
|
asap@2.0.6: {}
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
@ -5045,6 +5117,10 @@ snapshots:
|
||||||
argparse: 1.0.10
|
argparse: 1.0.10
|
||||||
esprima: 4.0.1
|
esprima: 4.0.1
|
||||||
|
|
||||||
|
js-yaml@5.3.0:
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
|
|
||||||
json-parse-even-better-errors@2.3.1: {}
|
json-parse-even-better-errors@2.3.1: {}
|
||||||
|
|
@ -5586,6 +5662,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
swagger-ui-dist@5.32.13:
|
||||||
|
dependencies:
|
||||||
|
'@scarf/scarf': 1.4.0
|
||||||
|
|
||||||
synckit@0.11.13:
|
synckit@0.11.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@pkgr/core': 0.3.6
|
'@pkgr/core': 0.3.6
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@parcel/watcher': true
|
'@parcel/watcher': true
|
||||||
'@prisma/engines': true
|
'@prisma/engines': true
|
||||||
|
'@scarf/scarf': false
|
||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
prisma: true
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@
|
||||||
"20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7",
|
"20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7",
|
||||||
"20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336",
|
"20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336",
|
||||||
"20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b",
|
"20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b",
|
||||||
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e"
|
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e",
|
||||||
|
"20260913140451_supplier_materials": "c1f59e082ddf97443c17d52745068d9e6cbafb3d4e1088126a150f555c1cf0a1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
CREATE TYPE "MaterialKind" AS ENUM ('WAX', 'FRAGRANCE', 'WICK', 'DYE', 'VESSEL', 'PACKAGING', 'LABEL', 'OTHER');
|
||||||
|
CREATE TYPE "MaterialUnit" AS ENUM ('GRAM', 'KILOGRAM', 'MILLILITRE', 'LITRE', 'PIECE', 'METRE');
|
||||||
|
|
||||||
|
CREATE TABLE "suppliers" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organization_id" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(160) NOT NULL,
|
||||||
|
"code" VARCHAR(32) NOT NULL,
|
||||||
|
"contact_name" VARCHAR(160) NOT NULL,
|
||||||
|
"email" VARCHAR(254) NOT NULL,
|
||||||
|
"phone" VARCHAR(24) NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
CONSTRAINT "suppliers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "materials" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organization_id" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(160) NOT NULL,
|
||||||
|
"code" VARCHAR(32) NOT NULL,
|
||||||
|
"kind" "MaterialKind" NOT NULL,
|
||||||
|
"unit" "MaterialUnit" NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
CONSTRAINT "materials_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "supplier_materials" (
|
||||||
|
"supplier_id" UUID NOT NULL,
|
||||||
|
"material_id" UUID NOT NULL,
|
||||||
|
"organization_id" UUID NOT NULL,
|
||||||
|
"supplier_sku" VARCHAR(64) NOT NULL,
|
||||||
|
"lead_time_days" INTEGER NOT NULL,
|
||||||
|
"min_order_quantity" DECIMAL(12,3) NOT NULL,
|
||||||
|
"unit_price" DECIMAL(12,2) NOT NULL,
|
||||||
|
"currency" CHAR(3) NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
CONSTRAINT "supplier_materials_pkey" PRIMARY KEY ("supplier_id", "material_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "suppliers_organization_id_code_key" ON "suppliers"("organization_id", "code");
|
||||||
|
CREATE UNIQUE INDEX "suppliers_id_organization_id_key" ON "suppliers"("id", "organization_id");
|
||||||
|
CREATE INDEX "suppliers_organization_id_active_name_id_idx" ON "suppliers"("organization_id", "active", "name", "id");
|
||||||
|
CREATE UNIQUE INDEX "materials_organization_id_code_key" ON "materials"("organization_id", "code");
|
||||||
|
CREATE UNIQUE INDEX "materials_id_organization_id_key" ON "materials"("id", "organization_id");
|
||||||
|
CREATE INDEX "materials_organization_id_active_kind_name_id_idx" ON "materials"("organization_id", "active", "kind", "name", "id");
|
||||||
|
CREATE INDEX "supplier_materials_material_id_organization_id_active_idx" ON "supplier_materials"("material_id", "organization_id", "active");
|
||||||
|
|
||||||
|
ALTER TABLE "suppliers" ADD CONSTRAINT "suppliers_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "materials" ADD CONSTRAINT "materials_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_supplier_id_organization_id_fkey" FOREIGN KEY ("supplier_id", "organization_id") REFERENCES "suppliers"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_material_id_organization_id_fkey" FOREIGN KEY ("material_id", "organization_id") REFERENCES "materials"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_bounds" CHECK ("lead_time_days" BETWEEN 0 AND 365 AND "min_order_quantity" > 0 AND "unit_price" >= 0 AND "currency" IN ('INR', 'USD', 'EUR', 'GBP'));
|
||||||
|
UPDATE "roles" SET "permissions" = ARRAY(SELECT DISTINCT permission FROM unnest("permissions" || ARRAY['procurement.read', 'procurement.manage']::text[]) AS permission ORDER BY permission) WHERE "is_system" = true;
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
enum MaterialKind {
|
||||||
|
WAX
|
||||||
|
FRAGRANCE
|
||||||
|
WICK
|
||||||
|
DYE
|
||||||
|
VESSEL
|
||||||
|
PACKAGING
|
||||||
|
LABEL
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MaterialUnit {
|
||||||
|
GRAM
|
||||||
|
KILOGRAM
|
||||||
|
MILLILITRE
|
||||||
|
LITRE
|
||||||
|
PIECE
|
||||||
|
METRE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Supplier {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organizationId String @map("organization_id") @db.Uuid
|
||||||
|
name String @db.VarChar(160)
|
||||||
|
code String @db.VarChar(32)
|
||||||
|
contactName String @map("contact_name") @db.VarChar(160)
|
||||||
|
email String @db.VarChar(254)
|
||||||
|
phone String @db.VarChar(24)
|
||||||
|
active Boolean @default(true)
|
||||||
|
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)
|
||||||
|
materials SupplierMaterial[]
|
||||||
|
|
||||||
|
@@unique([organizationId, code])
|
||||||
|
@@unique([id, organizationId])
|
||||||
|
@@index([organizationId, active, name, id])
|
||||||
|
@@map("suppliers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Material {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organizationId String @map("organization_id") @db.Uuid
|
||||||
|
name String @db.VarChar(160)
|
||||||
|
code String @db.VarChar(32)
|
||||||
|
kind MaterialKind
|
||||||
|
unit MaterialUnit
|
||||||
|
active Boolean @default(true)
|
||||||
|
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)
|
||||||
|
suppliers SupplierMaterial[]
|
||||||
|
|
||||||
|
@@unique([organizationId, code])
|
||||||
|
@@unique([id, organizationId])
|
||||||
|
@@index([organizationId, active, kind, name, id])
|
||||||
|
@@map("materials")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupplierMaterial {
|
||||||
|
supplierId String @map("supplier_id") @db.Uuid
|
||||||
|
materialId String @map("material_id") @db.Uuid
|
||||||
|
organizationId String @map("organization_id") @db.Uuid
|
||||||
|
supplierSku String @map("supplier_sku") @db.VarChar(64)
|
||||||
|
leadTimeDays Int @map("lead_time_days")
|
||||||
|
minOrderQuantity Decimal @map("min_order_quantity") @db.Decimal(12, 3)
|
||||||
|
unitPrice Decimal @map("unit_price") @db.Decimal(12, 2)
|
||||||
|
currency String @db.Char(3)
|
||||||
|
active Boolean @default(true)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
supplier Supplier @relation(fields: [supplierId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||||
|
material Material @relation(fields: [materialId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@id([supplierId, materialId])
|
||||||
|
@@index([materialId, organizationId, active])
|
||||||
|
@@map("supplier_materials")
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,8 @@ model Organization {
|
||||||
warehouses Warehouse[]
|
warehouses Warehouse[]
|
||||||
coupons Coupon[]
|
coupons Coupon[]
|
||||||
pricingPolicies PricingPolicy[]
|
pricingPolicies PricingPolicy[]
|
||||||
|
suppliers Supplier[]
|
||||||
|
materials Material[]
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
model User {
|
model User {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { OperationsModule } from './operations/operations.module';
|
||||||
import { CatalogModule } from './catalog/catalog.module';
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { AddressesModule } from './addresses/addresses.module';
|
import { AddressesModule } from './addresses/addresses.module';
|
||||||
import { InventoryModule } from './inventory/inventory.module';
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
|
import { ProcurementModule } from './procurement/procurement.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';
|
||||||
|
|
@ -16,6 +17,7 @@ import { HealthModule } from './health/health.module';
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
AddressesModule,
|
AddressesModule,
|
||||||
InventoryModule,
|
InventoryModule,
|
||||||
|
ProcurementModule,
|
||||||
CheckoutModule,
|
CheckoutModule,
|
||||||
OperationsModule,
|
OperationsModule,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,19 @@ export const COMMERCE_ERRORS = {
|
||||||
'Product variant is not available for reservation',
|
'Product variant is not available for reservation',
|
||||||
'Non-sellable variant reservation rejected',
|
'Non-sellable variant reservation rejected',
|
||||||
],
|
],
|
||||||
|
SUPPLIER_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Supplier not found',
|
||||||
|
'Scoped supplier lookup failed',
|
||||||
|
],
|
||||||
|
MATERIAL_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Material not found',
|
||||||
|
'Scoped material lookup failed',
|
||||||
|
],
|
||||||
|
SUPPLIER_MATERIAL_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Supplier material is unavailable',
|
||||||
|
'Scoped supplier-material compatibility lookup failed',
|
||||||
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||||
import { AppError } from './errors/app-error';
|
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(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) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ 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),
|
||||||
|
SWAGGER_ENABLED: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.transform((value) => value === 'true')
|
||||||
|
.optional(),
|
||||||
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(50).default(10),
|
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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { configureApp } from '../configure-app';
|
||||||
|
import { parseEnvironment } from '../config/environment';
|
||||||
|
import { SchemaPipe } from '../common/validation.pipe';
|
||||||
|
import { Public } from '../identity/access.decorator';
|
||||||
|
import { configureSwagger } from './configure-swagger';
|
||||||
|
|
||||||
|
@Controller('sample')
|
||||||
|
class SampleController {
|
||||||
|
@Public()
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@Body(
|
||||||
|
new SchemaPipe(
|
||||||
|
z.strictObject({
|
||||||
|
email: z.email(),
|
||||||
|
date: z.iso.datetime().transform((value) => new Date(value)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
input: unknown,
|
||||||
|
) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query(
|
||||||
|
new SchemaPipe(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
input: unknown,
|
||||||
|
) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(204)
|
||||||
|
logout() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Swagger documentation', () => {
|
||||||
|
async function fixture(nodeEnv: string, enabled?: string) {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
controllers: [SampleController],
|
||||||
|
}).compile();
|
||||||
|
const app = module.createNestApplication();
|
||||||
|
app.useLogger(false);
|
||||||
|
const env = parseEnvironment({
|
||||||
|
DATABASE_URL: 'postgresql://local/test',
|
||||||
|
NODE_ENV: nodeEnv,
|
||||||
|
SWAGGER_ENABLED: enabled,
|
||||||
|
});
|
||||||
|
configureApp(app, env);
|
||||||
|
configureSwagger(app, env);
|
||||||
|
await app.init();
|
||||||
|
return { app, api: request(app.getHttpServer()) };
|
||||||
|
}
|
||||||
|
it('serves UI, local assets and accurate input/auth/error documentation', async () => {
|
||||||
|
const { app, api } = await fixture('development');
|
||||||
|
try {
|
||||||
|
const ui = await api.get('/api/docs/').expect(200);
|
||||||
|
expect(ui.text).toContain('swagger-ui');
|
||||||
|
expect(ui.headers['content-security-policy']).not.toContain(
|
||||||
|
'upgrade-insecure-requests',
|
||||||
|
);
|
||||||
|
await api.get('/api/docs/swagger-ui-bundle.js').expect(200);
|
||||||
|
const init = await api.get('/api/docs/swagger-ui-init.js').expect(200);
|
||||||
|
expect(init.text).toContain('"persistAuthorization": false');
|
||||||
|
const { body: doc } = await api.get('/api/docs-json').expect(200);
|
||||||
|
const sample = doc.paths['/api/v1/sample'];
|
||||||
|
expect(sample.post.security).toEqual([]);
|
||||||
|
expect(sample.get.security).toEqual([{ bearer: [] }]);
|
||||||
|
expect(
|
||||||
|
sample.post.requestBody.content['application/json'].schema,
|
||||||
|
).toMatchObject({
|
||||||
|
required: ['email', 'date'],
|
||||||
|
properties: { email: { format: 'email' }, date: { type: 'string' } },
|
||||||
|
});
|
||||||
|
expect(sample.get.parameters).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'limit',
|
||||||
|
in: 'query',
|
||||||
|
schema: expect.objectContaining({ maximum: 100, default: 20 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
doc.paths['/api/v1/sample/logout'].post.responses['204'],
|
||||||
|
).toBeDefined();
|
||||||
|
expect(doc.components.schemas.ApiError.properties.code.enum).toContain(
|
||||||
|
'REQUEST_INVALID',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await api.get('/api/v1/sample')).headers['content-security-policy'],
|
||||||
|
).toContain('upgrade-insecure-requests');
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
['production', undefined],
|
||||||
|
['test', undefined],
|
||||||
|
['development', 'false'],
|
||||||
|
])('hides docs in %s when enabled=%s', async (mode, enabled) => {
|
||||||
|
const { app, api } = await fixture(mode!, enabled);
|
||||||
|
try {
|
||||||
|
await api.get('/api/docs-json').expect(404);
|
||||||
|
await api.get('/api/docs').expect(404);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('allows an explicit opt-in and rejects invalid settings', async () => {
|
||||||
|
const { app, api } = await fixture('production', 'true');
|
||||||
|
try {
|
||||||
|
await api.get('/api/docs-json').expect(200);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
expect(() =>
|
||||||
|
parseEnvironment({
|
||||||
|
DATABASE_URL: 'postgresql://local/test',
|
||||||
|
SWAGGER_ENABLED: 'yes',
|
||||||
|
}),
|
||||||
|
).toThrow('SWAGGER_ENABLED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import type { Environment } from '../config/environment';
|
||||||
|
import { documentErrors } from './document-errors';
|
||||||
|
import { enrichOperations } from './enrich-operations';
|
||||||
|
|
||||||
|
export function configureSwagger(
|
||||||
|
app: INestApplication,
|
||||||
|
environment: Environment,
|
||||||
|
): void {
|
||||||
|
if (!(environment.SWAGGER_ENABLED ?? environment.NODE_ENV === 'development'))
|
||||||
|
return;
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Mani Candles API')
|
||||||
|
.setDescription(
|
||||||
|
'Log in using the Auth endpoints, then paste the accessToken into Authorize. Requests run against this server and may change data. Input schemas come from runtime validation; cross-field business rules are enforced by the API. Payment and shipping integrations currently remain test blueprints.',
|
||||||
|
)
|
||||||
|
.setVersion('1')
|
||||||
|
.addBearerAuth({
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
description: 'Opaque session access token returned by login.',
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config, {
|
||||||
|
operationIdFactory: (controller, method) => `${controller}_${method}`,
|
||||||
|
});
|
||||||
|
enrichOperations(app, document);
|
||||||
|
documentErrors(document);
|
||||||
|
app.use(
|
||||||
|
'/api/docs',
|
||||||
|
helmet.contentSecurityPolicy({
|
||||||
|
directives: { upgradeInsecureRequests: null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
jsonDocumentUrl: '/api/docs-json',
|
||||||
|
raw: ['json'],
|
||||||
|
customSiteTitle: 'Mani Candles API',
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: false,
|
||||||
|
validatorUrl: null,
|
||||||
|
queryConfigEnabled: false,
|
||||||
|
docExpansion: 'none',
|
||||||
|
filter: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
tagsSorter: 'alpha',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||||
|
import { ERRORS } from '../common/errors/error-catalog';
|
||||||
|
|
||||||
|
export function documentErrors(document: OpenAPIObject): void {
|
||||||
|
document.components ??= {};
|
||||||
|
document.components.schemas ??= {};
|
||||||
|
document.components.schemas.ApiError = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['statusCode', 'code', 'message', 'requestId'],
|
||||||
|
properties: {
|
||||||
|
statusCode: { type: 'integer' },
|
||||||
|
code: { type: 'string', enum: Object.keys(ERRORS) },
|
||||||
|
message: { type: 'string' },
|
||||||
|
requestId: { type: 'string', format: 'uuid' },
|
||||||
|
fields: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const path of Object.values(document.paths)) {
|
||||||
|
for (const operation of Object.values(path)) {
|
||||||
|
if (
|
||||||
|
!operation ||
|
||||||
|
typeof operation !== 'object' ||
|
||||||
|
!('responses' in operation)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
operation.responses.default = {
|
||||||
|
description:
|
||||||
|
'Error response. A distinct code identifies the failure; requestId correlates with server logs. Validation errors can include field paths. Possible errors vary by endpoint.',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/ApiError' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants';
|
||||||
|
import { ModulesContainer } from '@nestjs/core';
|
||||||
|
import type {
|
||||||
|
OpenAPIObject,
|
||||||
|
OperationObject,
|
||||||
|
SchemaObject,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { SchemaPipe } from '../common/validation.pipe';
|
||||||
|
import {
|
||||||
|
PUBLIC_ROUTE,
|
||||||
|
REQUIRED_PERMISSION,
|
||||||
|
} from '../identity/access.decorator';
|
||||||
|
|
||||||
|
type Argument = { data?: string; pipes: unknown[] };
|
||||||
|
|
||||||
|
/** Reuse runtime validation metadata so documentation cannot drift from input DTOs. */
|
||||||
|
export function enrichOperations(
|
||||||
|
app: INestApplication,
|
||||||
|
document: OpenAPIObject,
|
||||||
|
): void {
|
||||||
|
const operations = new Map<string, OperationObject>();
|
||||||
|
for (const path of Object.values(document.paths)) {
|
||||||
|
for (const value of Object.values(path)) {
|
||||||
|
if (value && typeof value === 'object' && 'operationId' in value)
|
||||||
|
operations.set(value.operationId as string, value as OperationObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const module of app.get(ModulesContainer).values()) {
|
||||||
|
for (const { metatype } of module.controllers.values()) {
|
||||||
|
if (!metatype) continue;
|
||||||
|
const prototype = metatype.prototype as Record<string, object>;
|
||||||
|
for (const method of Object.getOwnPropertyNames(prototype)) {
|
||||||
|
const operation = operations.get(`${metatype.name}_${method}`);
|
||||||
|
if (!operation) continue;
|
||||||
|
const handler = prototype[method];
|
||||||
|
const isPublic =
|
||||||
|
Reflect.getMetadata(PUBLIC_ROUTE, handler) ??
|
||||||
|
Reflect.getMetadata(PUBLIC_ROUTE, metatype);
|
||||||
|
const permission =
|
||||||
|
Reflect.getMetadata(REQUIRED_PERMISSION, handler) ??
|
||||||
|
Reflect.getMetadata(REQUIRED_PERMISSION, metatype);
|
||||||
|
operation.security = isPublic ? [] : [{ bearer: [] }];
|
||||||
|
operation.summary = method.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
|
operation.description = permission
|
||||||
|
? `Required permission: ${permission}.`
|
||||||
|
: isPublic
|
||||||
|
? 'Public endpoint.'
|
||||||
|
: 'Requires a valid session; access is scoped to the authenticated principal.';
|
||||||
|
const argumentsMetadata: Record<string, Argument> =
|
||||||
|
Reflect.getMetadata(ROUTE_ARGS_METADATA, metatype, method) ?? {};
|
||||||
|
for (const [key, argument] of Object.entries(argumentsMetadata)) {
|
||||||
|
const pipe = argument.pipes.find(
|
||||||
|
(candidate) => candidate instanceof SchemaPipe,
|
||||||
|
);
|
||||||
|
if (!(pipe instanceof SchemaPipe)) continue;
|
||||||
|
const schema = z.toJSONSchema(pipe.schema, {
|
||||||
|
target: 'openapi-3.0',
|
||||||
|
io: 'input',
|
||||||
|
}) as SchemaObject;
|
||||||
|
if (key.startsWith('3:')) {
|
||||||
|
operation.requestBody = {
|
||||||
|
required: true,
|
||||||
|
content: { 'application/json': { schema } },
|
||||||
|
};
|
||||||
|
} else if (key.startsWith('4:')) {
|
||||||
|
operation.parameters = (operation.parameters ?? []).filter(
|
||||||
|
(item) => '$ref' in item || item.in !== 'query',
|
||||||
|
);
|
||||||
|
for (const [name, property] of Object.entries(
|
||||||
|
schema.properties ?? {},
|
||||||
|
)) {
|
||||||
|
operation.parameters.push({
|
||||||
|
name,
|
||||||
|
in: 'query',
|
||||||
|
required: schema.required?.includes(name) ?? false,
|
||||||
|
schema: property,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,5 +20,7 @@ export const PERMISSIONS = [
|
||||||
'inventory.adjust',
|
'inventory.adjust',
|
||||||
'inventory.reserve',
|
'inventory.reserve',
|
||||||
'inventory.commit',
|
'inventory.commit',
|
||||||
|
'procurement.read',
|
||||||
|
'procurement.manage',
|
||||||
] as const;
|
] as const;
|
||||||
export type Permission = (typeof PERMISSIONS)[number];
|
export type Permission = (typeof PERMISSIONS)[number];
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ import { AppModule } from './app.module';
|
||||||
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 { configureApp } from './configure-app';
|
import { configureApp } from './configure-app';
|
||||||
|
import { configureSwagger } from './documentation/configure-swagger';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const environment = app.get<Environment>(ENVIRONMENT);
|
const environment = app.get<Environment>(ENVIRONMENT);
|
||||||
configureApp(app, environment);
|
configureApp(app, environment);
|
||||||
|
configureSwagger(app, environment);
|
||||||
await app.listen(environment.PORT);
|
await app.listen(environment.PORT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
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 type { Principal } from '../identity/identity.types';
|
||||||
|
import { ProcurementStore } from './procurement.store';
|
||||||
|
import {
|
||||||
|
materialSchema,
|
||||||
|
procurementQuery,
|
||||||
|
supplierMaterialSchema,
|
||||||
|
supplierSchema,
|
||||||
|
type MaterialInput,
|
||||||
|
type ProcurementQuery,
|
||||||
|
type SupplierInput,
|
||||||
|
type SupplierMaterialInput,
|
||||||
|
} from './procurement.schemas';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class ProcurementController {
|
||||||
|
constructor(private readonly procurement: ProcurementStore) {}
|
||||||
|
@Get('suppliers')
|
||||||
|
@RequirePermission('procurement.read')
|
||||||
|
listSuppliers(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Query(new SchemaPipe(procurementQuery)) query: ProcurementQuery,
|
||||||
|
) {
|
||||||
|
return this.procurement.listSuppliers(actor, query);
|
||||||
|
}
|
||||||
|
@Post('suppliers')
|
||||||
|
@RequirePermission('procurement.manage')
|
||||||
|
createSupplier(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Body(new SchemaPipe(supplierSchema)) input: SupplierInput,
|
||||||
|
) {
|
||||||
|
return this.procurement.saveSupplier(actor, input);
|
||||||
|
}
|
||||||
|
@Put('suppliers/:id')
|
||||||
|
@RequirePermission('procurement.manage')
|
||||||
|
updateSupplier(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new SchemaPipe(supplierSchema)) input: SupplierInput,
|
||||||
|
) {
|
||||||
|
return this.procurement.saveSupplier(actor, input, id);
|
||||||
|
}
|
||||||
|
@Get('suppliers/:supplierId/materials')
|
||||||
|
@RequirePermission('procurement.read')
|
||||||
|
listSupplierMaterials(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('supplierId', ParseUUIDPipe) supplierId: string,
|
||||||
|
) {
|
||||||
|
return this.procurement.listSupplierMaterials(actor, supplierId);
|
||||||
|
}
|
||||||
|
@Put('suppliers/:supplierId/materials/:materialId')
|
||||||
|
@RequirePermission('procurement.manage')
|
||||||
|
saveSupplierMaterial(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('supplierId', ParseUUIDPipe) supplierId: string,
|
||||||
|
@Param('materialId', ParseUUIDPipe) materialId: string,
|
||||||
|
@Body(new SchemaPipe(supplierMaterialSchema)) input: SupplierMaterialInput,
|
||||||
|
) {
|
||||||
|
return this.procurement.saveSupplierMaterial(
|
||||||
|
actor,
|
||||||
|
supplierId,
|
||||||
|
materialId,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@Get('materials')
|
||||||
|
@RequirePermission('procurement.read')
|
||||||
|
listMaterials(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Query(new SchemaPipe(procurementQuery)) query: ProcurementQuery,
|
||||||
|
) {
|
||||||
|
return this.procurement.listMaterials(actor, query);
|
||||||
|
}
|
||||||
|
@Post('materials')
|
||||||
|
@RequirePermission('procurement.manage')
|
||||||
|
createMaterial(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Body(new SchemaPipe(materialSchema)) input: MaterialInput,
|
||||||
|
) {
|
||||||
|
return this.procurement.saveMaterial(actor, input);
|
||||||
|
}
|
||||||
|
@Put('materials/:id')
|
||||||
|
@RequirePermission('procurement.manage')
|
||||||
|
updateMaterial(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new SchemaPipe(materialSchema)) input: MaterialInput,
|
||||||
|
) {
|
||||||
|
return this.procurement.saveMaterial(actor, input, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabaseModule } from '../database/database.module';
|
||||||
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
|
import { ProcurementController } from './procurement.controller';
|
||||||
|
import { ProcurementStore } from './procurement.store';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, IdentityModule],
|
||||||
|
providers: [ProcurementStore],
|
||||||
|
controllers: [ProcurementController],
|
||||||
|
})
|
||||||
|
export class ProcurementModule {}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CURRENCIES } from '../common/currency';
|
||||||
|
import { text } from '../common/input';
|
||||||
|
import { pageSchema } from '../identity/identity.schemas';
|
||||||
|
|
||||||
|
const code = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.max(32)
|
||||||
|
.regex(/^[A-Z0-9][A-Z0-9_-]*$/);
|
||||||
|
const decimal = (scale: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^(0|[1-9]\d{0,8})\.\d+$/)
|
||||||
|
.refine((value) => value.split('.').at(-1)?.length === scale);
|
||||||
|
|
||||||
|
export const supplierSchema = z
|
||||||
|
.object({
|
||||||
|
name: text(160),
|
||||||
|
code,
|
||||||
|
contactName: text(160),
|
||||||
|
email: z
|
||||||
|
.email()
|
||||||
|
.max(254)
|
||||||
|
.transform((value) => value.toLowerCase()),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(24)
|
||||||
|
.regex(/^\+?[0-9 ()-]{7,24}$/),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const materialSchema = z
|
||||||
|
.object({
|
||||||
|
name: text(160),
|
||||||
|
code,
|
||||||
|
kind: z.enum([
|
||||||
|
'WAX',
|
||||||
|
'FRAGRANCE',
|
||||||
|
'WICK',
|
||||||
|
'DYE',
|
||||||
|
'VESSEL',
|
||||||
|
'PACKAGING',
|
||||||
|
'LABEL',
|
||||||
|
'OTHER',
|
||||||
|
]),
|
||||||
|
unit: z.enum(['GRAM', 'KILOGRAM', 'MILLILITRE', 'LITRE', 'PIECE', 'METRE']),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const supplierMaterialSchema = z
|
||||||
|
.object({
|
||||||
|
supplierSku: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[^<>\u0000-\u001F\u007F]*$/),
|
||||||
|
leadTimeDays: z.number().int().min(0).max(365),
|
||||||
|
minOrderQuantity: decimal(3).refine((value) => value !== '0.000'),
|
||||||
|
unitPrice: decimal(2),
|
||||||
|
currency: z.enum(CURRENCIES).default('INR'),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const procurementQuery = pageSchema
|
||||||
|
.extend({
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
search: text(100).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export type SupplierInput = z.infer<typeof supplierSchema>;
|
||||||
|
export type MaterialInput = z.infer<typeof materialSchema>;
|
||||||
|
export type SupplierMaterialInput = z.infer<typeof supplierMaterialSchema>;
|
||||||
|
export type ProcurementQuery = z.infer<typeof procurementQuery>;
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
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 {
|
||||||
|
MaterialInput,
|
||||||
|
ProcurementQuery,
|
||||||
|
SupplierInput,
|
||||||
|
SupplierMaterialInput,
|
||||||
|
} from './procurement.schemas';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProcurementStore {
|
||||||
|
constructor(
|
||||||
|
private readonly db: DatabaseService,
|
||||||
|
private readonly access: AccessStore,
|
||||||
|
) {}
|
||||||
|
listSuppliers(actor: Principal, query: ProcurementQuery) {
|
||||||
|
return this.db.supplier.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...(query.active === undefined ? {} : { active: query.active }),
|
||||||
|
...(query.search
|
||||||
|
? { name: { contains: query.search, mode: 'insensitive' } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||||
|
take: query.limit,
|
||||||
|
skip: query.offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
listMaterials(actor: Principal, query: ProcurementQuery) {
|
||||||
|
return this.db.material.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...(query.active === undefined ? {} : { active: query.active }),
|
||||||
|
...(query.search
|
||||||
|
? { name: { contains: query.search, mode: 'insensitive' } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||||
|
take: query.limit,
|
||||||
|
skip: query.offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveSupplier(actor: Principal, input: SupplierInput, id?: string) {
|
||||||
|
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
|
||||||
|
if (
|
||||||
|
id &&
|
||||||
|
!(await tx.supplier.findFirst({
|
||||||
|
where: { id, organizationId: actor.organizationId },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new AppError('SUPPLIER_NOT_FOUND');
|
||||||
|
const supplier = id
|
||||||
|
? await tx.supplier.update({ where: { id }, data: input })
|
||||||
|
: await tx.supplier.create({
|
||||||
|
data: { ...input, organizationId: actor.organizationId },
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
id ? 'supplier.updated' : 'supplier.created',
|
||||||
|
supplier.id,
|
||||||
|
);
|
||||||
|
return supplier;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveMaterial(actor: Principal, input: MaterialInput, id?: string) {
|
||||||
|
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
|
||||||
|
if (
|
||||||
|
id &&
|
||||||
|
!(await tx.material.findFirst({
|
||||||
|
where: { id, organizationId: actor.organizationId },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new AppError('MATERIAL_NOT_FOUND');
|
||||||
|
const material = id
|
||||||
|
? await tx.material.update({ where: { id }, data: input })
|
||||||
|
: await tx.material.create({
|
||||||
|
data: { ...input, organizationId: actor.organizationId },
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
id ? 'material.updated' : 'material.created',
|
||||||
|
material.id,
|
||||||
|
);
|
||||||
|
return material;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveSupplierMaterial(
|
||||||
|
actor: Principal,
|
||||||
|
supplierId: string,
|
||||||
|
materialId: string,
|
||||||
|
input: SupplierMaterialInput,
|
||||||
|
) {
|
||||||
|
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
|
||||||
|
if (
|
||||||
|
!(await tx.supplier.findFirst({
|
||||||
|
where: { id: supplierId, organizationId: actor.organizationId },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new AppError('SUPPLIER_NOT_FOUND');
|
||||||
|
if (
|
||||||
|
!(await tx.material.findFirst({
|
||||||
|
where: { id: materialId, organizationId: actor.organizationId },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new AppError('MATERIAL_NOT_FOUND');
|
||||||
|
const relation = await tx.supplierMaterial.upsert({
|
||||||
|
where: { supplierId_materialId: { supplierId, materialId } },
|
||||||
|
create: {
|
||||||
|
...input,
|
||||||
|
supplierId,
|
||||||
|
materialId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
},
|
||||||
|
update: input,
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
'supplier_material.saved',
|
||||||
|
supplierId,
|
||||||
|
);
|
||||||
|
return relation;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async listSupplierMaterials(actor: Principal, supplierId: string) {
|
||||||
|
if (
|
||||||
|
!(await this.db.supplier.findFirst({
|
||||||
|
where: { id: supplierId, organizationId: actor.organizationId },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new AppError('SUPPLIER_NOT_FOUND');
|
||||||
|
return this.db.supplierMaterial.findMany({
|
||||||
|
where: { supplierId, organizationId: actor.organizationId },
|
||||||
|
include: { material: true },
|
||||||
|
orderBy: { material: { name: 'asc' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,10 @@ import { DatabaseService } from '../../src/database/database.service';
|
||||||
import { BootstrapService } from '../../src/identity/bootstrap.service';
|
import { BootstrapService } from '../../src/identity/bootstrap.service';
|
||||||
import { RecoveryMailer } from '../../src/identity/recovery-mailer';
|
import { RecoveryMailer } from '../../src/identity/recovery-mailer';
|
||||||
import { configureApp } from '../../src/configure-app';
|
import { configureApp } from '../../src/configure-app';
|
||||||
|
import { configureSwagger } from '../../src/documentation/configure-swagger';
|
||||||
|
|
||||||
export const ownerPassword = 'correct horse battery staple';
|
export const ownerPassword = 'correct horse battery staple';
|
||||||
export async function identityApp() {
|
export async function identityApp(swagger = false) {
|
||||||
const database = await testDatabase();
|
const database = await testDatabase();
|
||||||
const env = parseEnvironment({
|
const env = parseEnvironment({
|
||||||
DATABASE_URL: database.connectionUrl,
|
DATABASE_URL: database.connectionUrl,
|
||||||
|
|
@ -31,6 +32,7 @@ export async function identityApp() {
|
||||||
const app = module.createNestApplication();
|
const app = module.createNestApplication();
|
||||||
app.useLogger(false);
|
app.useLogger(false);
|
||||||
configureApp(app, env);
|
configureApp(app, env);
|
||||||
|
if (swagger) configureSwagger(app, { ...env, SWAGGER_ENABLED: true });
|
||||||
await app.init();
|
await app.init();
|
||||||
const db = app.get(DatabaseService);
|
const db = app.get(DatabaseService);
|
||||||
const owner = await app.get(BootstrapService).createOwner('Mani Candles', {
|
const owner = await app.get(BootstrapService).createOwner('Mani Candles', {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { secondActor } from './helpers/commerce';
|
||||||
|
|
||||||
|
describe('supplier and material API', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
const auth = { type: 'bearer' as const };
|
||||||
|
const supplierInput = {
|
||||||
|
name: 'Candle Supply Co',
|
||||||
|
code: 'CANDLE-SUPPLY',
|
||||||
|
contactName: 'Asha Shah',
|
||||||
|
email: 'asha@example.com',
|
||||||
|
phone: '+919876543210',
|
||||||
|
};
|
||||||
|
const materialInput = {
|
||||||
|
name: 'Soy Wax',
|
||||||
|
code: 'SOY-WAX',
|
||||||
|
kind: 'WAX',
|
||||||
|
unit: 'KILOGRAM',
|
||||||
|
};
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx?.close();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await ctx.clearLimits();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates scoped master data and a supplier-material quote', async () => {
|
||||||
|
const supplier = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/suppliers')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send(supplierInput)
|
||||||
|
.expect(201);
|
||||||
|
const material = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/materials')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send(materialInput)
|
||||||
|
.expect(201);
|
||||||
|
const relation = await ctx
|
||||||
|
.api()
|
||||||
|
.put(
|
||||||
|
`/api/v1/suppliers/${supplier.body.id}/materials/${material.body.id}`,
|
||||||
|
)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({
|
||||||
|
supplierSku: 'SW-25',
|
||||||
|
leadTimeDays: 14,
|
||||||
|
minOrderQuantity: '25.000',
|
||||||
|
unitPrice: '320.00',
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
expect(relation.body).toMatchObject({
|
||||||
|
supplierId: supplier.body.id,
|
||||||
|
materialId: material.body.id,
|
||||||
|
currency: 'INR',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
const listed = await ctx
|
||||||
|
.api()
|
||||||
|
.get(`/api/v1/suppliers/${supplier.body.id}/materials`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.expect(200);
|
||||||
|
expect(listed.body[0].material).toMatchObject({
|
||||||
|
id: material.body.id,
|
||||||
|
name: 'Soy Wax',
|
||||||
|
});
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/suppliers/${supplier.body.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...supplierInput, active: false })
|
||||||
|
.expect(200);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/materials/${material.body.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...materialInput, name: 'Refined Soy Wax' })
|
||||||
|
.expect(200);
|
||||||
|
const activeOnly = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/suppliers?active=true')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.expect(200);
|
||||||
|
expect(activeOnly.body).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
await ctx.db.auditEvent.count({
|
||||||
|
where: {
|
||||||
|
organizationId: ctx.owner.organizationId,
|
||||||
|
action: 'supplier_material.saved',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces permissions, organization scope and strict validation', async () => {
|
||||||
|
const reader = await secondActor(ctx, true, ['procurement.read']);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/suppliers')
|
||||||
|
.auth(reader.token, auth)
|
||||||
|
.expect(200);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/suppliers')
|
||||||
|
.auth(reader.token, auth)
|
||||||
|
.send(supplierInput)
|
||||||
|
.expect(403);
|
||||||
|
const outsider = await secondActor(ctx, false, ['procurement.manage']);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/suppliers/${randomUUID()}`)
|
||||||
|
.auth(outsider.token, auth)
|
||||||
|
.send(supplierInput)
|
||||||
|
.expect(404);
|
||||||
|
const invalid = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/materials')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...materialInput, code: '<unsafe>' })
|
||||||
|
.expect(400);
|
||||||
|
expect(invalid.body.code).toBe('REQUEST_INVALID');
|
||||||
|
const duplicateInput = {
|
||||||
|
...materialInput,
|
||||||
|
code: `SOY-${randomUUID().slice(0, 8)}`,
|
||||||
|
};
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/materials')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send(duplicateInput)
|
||||||
|
.expect(201);
|
||||||
|
const duplicate = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/materials')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send(duplicateInput)
|
||||||
|
.expect(409);
|
||||||
|
expect(duplicate.body.code).toBe('RECORD_CONFLICT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns distinct missing-resource errors for compatibility mutations', async () => {
|
||||||
|
const missingSupplier = await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/suppliers/${randomUUID()}/materials/${randomUUID()}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({
|
||||||
|
supplierSku: 'X',
|
||||||
|
leadTimeDays: 0,
|
||||||
|
minOrderQuantity: '1.000',
|
||||||
|
unitPrice: '0.00',
|
||||||
|
})
|
||||||
|
.expect(404);
|
||||||
|
expect(missingSupplier.body.code).toBe('SUPPLIER_NOT_FOUND');
|
||||||
|
const missingList = await ctx
|
||||||
|
.api()
|
||||||
|
.get(`/api/v1/suppliers/${randomUUID()}/materials`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.expect(404);
|
||||||
|
expect(missingList.body.code).toBe('SUPPLIER_NOT_FOUND');
|
||||||
|
const supplier = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/suppliers')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...supplierInput, code: 'S-' + randomUUID().slice(0, 8) })
|
||||||
|
.expect(201);
|
||||||
|
const missingMaterial = await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/suppliers/${supplier.body.id}/materials/${randomUUID()}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({
|
||||||
|
supplierSku: 'X',
|
||||||
|
leadTimeDays: 0,
|
||||||
|
minOrderQuantity: '1.000',
|
||||||
|
unitPrice: '0.00',
|
||||||
|
})
|
||||||
|
.expect(404);
|
||||||
|
expect(missingMaterial.body.code).toBe('MATERIAL_NOT_FOUND');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { identityApp } from './helpers/identity-app';
|
||||||
|
|
||||||
|
describe('Application OpenAPI', () => {
|
||||||
|
it('exports all application controllers and accepts a documented login', async () => {
|
||||||
|
const fixture = await identityApp(true);
|
||||||
|
try {
|
||||||
|
const { body: doc } = await fixture
|
||||||
|
.api()
|
||||||
|
.get('/api/docs-json')
|
||||||
|
.expect(200);
|
||||||
|
expect(Object.keys(doc.paths).length).toBeGreaterThan(30);
|
||||||
|
expect(
|
||||||
|
doc.paths['/api/v1/auth/login'].post.requestBody.content[
|
||||||
|
'application/json'
|
||||||
|
].schema.required,
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining(['organizationId', 'email', 'password']),
|
||||||
|
);
|
||||||
|
const login = await fixture.login();
|
||||||
|
await fixture
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${login.body.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
for (const path of Object.values(doc.paths) as Record<string, any>[]) {
|
||||||
|
for (const operation of Object.values(path)) {
|
||||||
|
expect(operation.security).toBeDefined();
|
||||||
|
expect(operation.responses.default).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue