Files
s.esmaeiliandClaude Opus 5 71192ba2ba parity(docs): Phase 0 inventory, mapping and draft PROTECTED list
Read-only inventory of all 7 projects (3 reference + 4 target) produced by
parallel subagents, every claim cited path:line. No source code changed.

- 00-INVENTORY.md  consolidated mapping: stack, backend, admin, user apps
- PROTECTED.md     draft protected list (awaiting user confirmation)
- ISSUES.md        26 logged issues (security, money/stock, validation, dead code)
- CHANGELOG.md     change log scaffold
- PROGRESS.md      resumable state + Q1-Q5 blocking Phase 1
- _inv-*.md        7 raw per-project inventories (8111 lines)

Key finding: the reference is a gold-jewellery B2B/B2C e-commerce system, not a
gold bank. Buy/sell, deposit/withdraw, transfer, KYC levels, payment callbacks,
cron and websockets do not exist in it, so they cannot be parity gaps.

[ref: henza_server, henza_admin, henza_pwa]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 18:09:46 +03:30

1180 lines
95 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Inventory — apps/api (target backend)
Evidence-based inventory. All `path:line` citations are relative to `apps/api/`.
Read-only audit; no source file was modified.
---
## 1. Stack & versions
Source: `package.json`.
| Item | Value | Cite |
|---|---|---|
| Package name / version | `api` @ `0.0.1`, private | package.json:2-4 |
| NestJS core | `@nestjs/core` ^10.4.0 | package.json:27 |
| NestJS common | `@nestjs/common` ^10.4.0 | package.json:26 |
| NestJS platform | `@nestjs/platform-express` ^10.4.0 | package.json:30 |
| NestJS JWT | `@nestjs/jwt` ^12.0.1 | package.json:28 |
| NestJS Passport | `@nestjs/passport` ^10.0.3 | package.json:29 |
| Prisma client | `@prisma/client` ^5.19.0 | package.json:31 |
| Prisma CLI (dev) | `prisma` ^5.19.0 | package.json:47 |
| bcryptjs | ^3.0.3 | package.json:32 |
| class-transformer | ^0.5.1 | package.json:33 |
| class-validator | ^0.14.1 | package.json:34 |
| passport / passport-jwt | ^0.7.0 / ^4.0.1 | package.json:35-36 |
| rxjs | ^7.8.1 | package.json:38 |
| Workspace dep | `@gold-bank/shared-types` (workspace:*) | package.json:25 |
| TypeScript | ^5.5.0 | package.json:50 |
| esbuild (release bundler) | ^0.25.0 | package.json:46 |
| ts-node | ^10.9.2 | package.json:49 |
**NOT present** (relevant negatives): no `@nestjs/config`, no `@nestjs/schedule`, no `@nestjs/websockets`/`socket.io`, no `@nestjs/swagger`, no `@nestjs/throttler`, no HTTP client (axios), no SMS/payment SDK, no `multer` types (multer ships inside platform-express; a local interface is declared instead — admin-uploads.controller.ts:21-26).
Scripts: `dev` = `nest start --watch`; `build:release` = prisma generate → clean-dist → nest build → bundle-release (package.json:7-9). Prisma seed command: `ts-node --transpile-only prisma/seed.ts` (package.json:19-21).
---
## 2. Bootstrap
### 2.1 `src/main.ts`
| Concern | Value | Cite |
|---|---|---|
| Pre-boot hook | `await prepareDatabase()` — runs SQL migrations + one-time seed BEFORE Nest is created | main.ts:19 |
| App type | `NestExpressApplication` | main.ts:21 |
| Global prefix | `api` | main.ts:22 |
| Global pipe | `ValidationPipe({ whitelist: true, transform: true })` — no `forbidNonWhitelisted`, no `transformOptions` | main.ts:23 |
| Global filter | `PrismaExceptionFilter` | main.ts:24 |
| Global interceptors | **NONE registered** | (absent in main.ts) |
| CORS | `app.enableCors()` — defaults, i.e. `origin: *`, no credentials, no allowlist | main.ts:25 |
| Static assets | `useStaticAssets(UPLOAD_DIR ?? cwd/storage/uploads, { prefix: '/uploads/', index:false, dotfiles:'deny' })` — mounted OUTSIDE the `api` prefix | main.ts:28-36 |
| Custom MIME | `.avif → image/avif` injected via `setHeaders` (serve-static's bundled mime predates avif) | main.ts:11-16, 32-35 |
| Port | `Number(process.env.PORT ?? 3000)` | main.ts:37 |
| Bind host | `0.0.0.0` (explicit, for nginx/container reachability) | main.ts:41 |
| EADDRINUSE | Logs guidance and `process.exit(1)`; explicitly does NOT fall back to another port | main.ts:42-52 |
Global guards are NOT registered in main.ts — they are registered via `APP_GUARD` in AuthModule (see §9).
### 2.2 `src/app.module.ts`
`@Module({ imports: [...] })`, no controllers/providers of its own (app.module.ts:28-57). 24 imported modules, in declared order:
| # | Module | Cite |
|---|---|---|
| 1 | PrismaModule | app.module.ts:30 |
| 2 | AuthModule | app.module.ts:31 |
| 3 | AdminTableModule | app.module.ts:32 |
| 4 | AdminOrdersModule | app.module.ts:33 |
| 5 | AdminCitiesModule | app.module.ts:34 |
| 6 | AdminPreOrdersModule | app.module.ts:35 |
| 7 | AdminWorkshopsModule | app.module.ts:36 |
| 8 | AdminCustomersModule | app.module.ts:37 |
| 9 | AdminCommentsModule | app.module.ts:38 |
| 10 | AdminProductsModule | app.module.ts:39 |
| 11 | AdminCategoriesModule | app.module.ts:40 |
| 12 | AdminDocumentsModule | app.module.ts:41 |
| 13 | AdminPromotionsModule | app.module.ts:42 |
| 14 | AdminUsersModule | app.module.ts:43 |
| 15 | AdminBranchesModule | app.module.ts:44 |
| 16 | AdminInvoicesModule | app.module.ts:45 |
| 17 | AdminMiscModule | app.module.ts:46 |
| 18 | AdminUploadsModule | app.module.ts:47 |
| 19 | AdminDashboardModule | app.module.ts:48 |
| 20 | CatalogModule | app.module.ts:49 |
| 21 | CustomersModule | app.module.ts:50 |
| 22 | SalesAgentModule | app.module.ts:51 |
| 23 | OrdersModule | app.module.ts:52 |
| 24 | AccountingModule | app.module.ts:53 |
| 25 | PricingModule | app.module.ts:54 |
(25 entries; `PrismaModule` is infrastructure, giving 24 feature/infra modules under `src/modules/` + prisma.)
---
## 3. Modules (every module under `src/`)
| Module file | Controllers | Services / Providers | Imports | Exports |
|---|---|---|---|---|
| `src/prisma/prisma.module.ts` | — | PrismaService (prisma.module.ts:6) | — | PrismaService (:7) |
| `src/modules/auth/auth.module.ts` | AuthController (:24) | AuthService, JwtStrategy, `APP_GUARD`→JwtAuthGuard, `APP_GUARD`→PermissionsGuard (:25-31) | PrismaModule, PassportModule, `JwtModule.register` (:12-23) | AuthService (:32) |
| `src/modules/admin-table/admin-table.module.ts` | — | TableService (:10) | — | TableService (:11) |
| `src/modules/admin-orders/admin-orders.module.ts` | AdminOrdersController (:9) | AdminOrdersService (:10) | PrismaModule, OrdersModule (:8) | — |
| `src/modules/admin-cities/admin-cities.module.ts` | AdminCitiesController (:7) | — (uses PrismaService directly in controller, admin-cities.controller.ts:13) | PrismaModule (:6) | — |
| `src/modules/admin-preorders/admin-preorders.module.ts` | AdminPreOrdersController (:8) | AdminPreOrdersService (:9) | PrismaModule (:7) | — |
| `src/modules/admin-workshops/admin-workshops.module.ts` | AdminWorkshopsController (:8) | — (reuses AdminBranchesService) | PrismaModule, AdminBranchesModule (:7) | — |
| `src/modules/admin-customers/admin-customers.module.ts` | AdminCustomersController (:8) | AdminCustomersService (:9) | PrismaModule (:7) | AdminCustomersService (:10) |
| `src/modules/admin-comments/admin-comments.module.ts` | AdminCommentsController (:8) | AdminCommentsService (:9) | PrismaModule (:7) | — |
| `src/modules/admin-products/admin-products.module.ts` | AdminProductsController (:8) | AdminProductsService (:9) | PrismaModule (:7) | — |
| `src/modules/admin-categories/admin-categories.module.ts` | AdminCategoriesController (:8) | AdminCategoriesService (:9) | PrismaModule (:7) | — |
| `src/modules/admin-documents/admin-documents.module.ts` | AdminDocumentsController (:9) | AdminDocumentsService (:10) | PrismaModule, AccountingModule (:8) | — |
| `src/modules/admin-promotions/admin-promotions.module.ts` | AdminPromotionsController (:8) | AdminPromotionsService (:9) | PrismaModule (:7) | — |
| `src/modules/admin-users/admin-users.module.ts` | AdminUsersController (:8) | AdminUsersService (:9) | PrismaModule (:7) | — |
| `src/modules/admin-branches/admin-branches.module.ts` | AdminBranchesController (:8) | AdminBranchesService (:9) | PrismaModule (:7) | AdminBranchesService (:12) |
| `src/modules/admin-invoices/admin-invoices.module.ts` | AdminInvoicesController (:8) | AdminInvoicesService (:9) | PrismaModule (:7) | — |
| `src/modules/admin-misc/admin-misc.module.ts` | AdminMiscController, **PublicSliderController** (:9) | AdminMiscService (:10) | PrismaModule (:8) | — |
| `src/modules/admin-uploads/admin-uploads.module.ts` | AdminUploadsController, **PublicUploadsController** (:9) | AdminUploadsService (:10) | PrismaModule (:8) | AdminUploadsService (:11) |
| `src/modules/admin-dashboard/admin-dashboard.module.ts` | AdminDashboardController (:8) | AdminDashboardService (:9) | PrismaModule (:7) | — |
| `src/modules/catalog/catalog.module.ts` | CatalogController (:6) | CatalogService (:7) | — | CatalogService (:8) |
| `src/modules/customers/customers.module.ts` | CustomersController (:6) | CustomersService (:7) | — | CustomersService (:8) |
| `src/modules/sales-agent/sales-agent.module.ts` | SalesAgentController (:6) | SalesAgentService (:7) | — | SalesAgentService (:8) |
| `src/modules/orders/orders.module.ts` | OrdersController (:9) | OrdersService (:10) | CatalogModule, AccountingModule (:8) | OrdersService (:11) |
| `src/modules/accounting/accounting.module.ts` | AccountingController (:6) | LedgerService (:7) | — | LedgerService (:8) |
| `src/modules/pricing/pricing.module.ts` | PricingController (:6) | PricingService (:7) | — | PricingService (:8) |
**Controllers: 25.** **Services: 22** (+ TableService, PrismaService).
---
## 4. Endpoints (exhaustive)
Global prefix `api` (main.ts:22) is included in every path below. `@Public()` = JwtAuthGuard bypassed (guards.ts:15). No `@RequirePermission` = authenticated but no rid check.
### 4.1 AuthController — `@Controller('admin/auth')` (auth.controller.ts:15)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/auth/login` | `login` (auth.controller.ts:22) | `@Public()`, `@HttpCode(200)` (:19,21) | `LoginDto` (:22) |
| GET | `/api/admin/auth/me` | `me` (auth.controller.ts:28) | auth required; `@CurrentAdmin()` (:28) | — |
### 4.2 AdminOrdersController — `@Controller('admin/orders')` (admin-orders.controller.ts:16)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/orders/list` | `list` (:24) | `@HttpCode(200)`, `@RequirePermission('order.list')` (:22-23), `@CurrentAdmin()` | `TableQueryDto` (:24) |
| POST | `/api/admin/orders/wholesale/list` | `listWholesale` (:46) | `@HttpCode(200)`, `@RequirePermission('order.omde')` (:44-45) | `TableQueryDto` |
| POST | `/api/admin/orders/tamin/list` | `listTamin` (:54) | `@HttpCode(200)`, `@RequirePermission('order.tamin')` (:52-53) | `TableQueryDto` |
| POST | `/api/admin/orders/delete` | `remove` (:62) | `@HttpCode(200)`, `@RequirePermission('order.omde')` (:60-61) | inline `{ id: string }` |
| POST | `/api/admin/orders/create` | `createFromAdmin` (:70) | `@HttpCode(200)`, `@RequirePermission('order.create')` (:68-69), `@CurrentAdmin()` | inline `{ customer_id, order_items[], sts?, note? }` (:72-77) |
| GET | `/api/admin/orders/type-options` | `typeOptions` (:86) | `@RequirePermission('order.create')` (:85) | — |
| GET | `/api/admin/orders/status-options` | `statusOptions` (:92) | `@RequirePermission('order.list')` (:91) | — |
| POST | `/api/admin/orders/status` | `setStatus` (:100) | `@HttpCode(200)`, `@RequirePermission('order.list')` (:98-99), `@CurrentAdmin()` | `UpdateOrderStatusDto` (admin-orders/update-status.dto.ts) |
| GET | `/api/admin/orders/:id` | `findOne` (:106) | `@RequirePermission('order.list')` (:105) | — |
### 4.3 AdminCitiesController — `@Controller('admin/cities')` (admin-cities.controller.ts:11)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/cities/list` | `list` (:17) | `@HttpCode(200)`; **no rid, but NOT `@Public()`** → JWT still required (:8-10 comment claims ungated; only the rid check is skipped) | — |
### 4.4 AdminPreOrdersController — `@Controller('admin/pre-orders')` (admin-preorders.controller.ts:13)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/pre-orders/list` | `list` (:20) | `@HttpCode(200)`, `@RequirePermission('order.makeOrder')` (:18-19) | `TableQueryDto` |
| GET | `/api/admin/pre-orders/status-options` | `statusOptions` (:26) | `@RequirePermission('order.makeOrder')` (:25) | — |
| GET | `/api/admin/pre-orders/:id` | `findOne` (:32) | `@RequirePermission('order.makeOrder')` (:31) | — |
| PUT | `/api/admin/pre-orders/:id` | `update` (:38) | `@RequirePermission('order.makeOrder')` (:37) | `Record<string, unknown>` (untyped) |
| DELETE | `/api/admin/pre-orders/:id` | `remove` (:44) | `@RequirePermission('order.makeOrder')` (:43) | — |
### 4.5 AdminWorkshopsController — `@Controller('admin/workshops')` (admin-workshops.controller.ts:18)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/workshops/list` | `list` (:24) | `@HttpCode(200)`; **no rid** (:22-23) | `TableQueryDto` |
| POST | `/api/admin/workshops` | `create` (:31) | `@HttpCode(200)`, `@RequirePermission('workshop.create')` (:29-30) | `Record<string, unknown>` |
| GET | `/api/admin/workshops/active/:id` | `toggleActive` (:37) | `@RequirePermission('workshop.list')` (:36) | — |
| GET | `/api/admin/workshops/:id` | `findOne` (:43) | `@RequirePermission('workshop.list')` (:42) | — |
| PUT | `/api/admin/workshops/:id` | `update` (:49) | `@RequirePermission('workshop.list')` (:48) | `Record<string, unknown>` |
### 4.6 AdminCustomersController — `@Controller('admin/customers-omde')` (admin-customers.controller.ts:17)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/customers-omde/list` | `listAll` (:28) | `@HttpCode(200)`, `@RequirePermission('persons.list.client.omde')` (:26-27) | `TableQueryDto` |
| POST | `/api/admin/customers-omde/banker/list` | `listBanker` (:35) | `@HttpCode(200)`, `persons.list.client.omde` (:33-34) | `TableQueryDto` |
| POST | `/api/admin/customers-omde/shopkeeper/list` | `listShopkeeper` (:42) | `@HttpCode(200)`, `persons.list.client.omde` (:40-41) | `TableQueryDto` |
| POST | `/api/admin/customers-omde/non-verified/list` | `listNonVerified` (:49) | `@HttpCode(200)`, `persons.list.client.omde` (:47-48) | `TableQueryDto` |
| POST | `/api/admin/customers-omde/archived/list` | `listArchived` (:56) | `@HttpCode(200)`, `persons.list.client.omde` (:54-55) | `TableQueryDto` |
| POST | `/api/admin/customers-omde/single/list` | `listSingle` (:64) | `@HttpCode(200)`, `persons.list.client.single` (:62-63), `@CurrentAdmin()` (branch scoping :65) | `TableQueryDto` |
| POST | `/api/admin/customers-omde/single` | `createSingle` (:72) | `@HttpCode(200)`, `persons.create` (:70-71) | `Record<string, unknown>` |
| GET | `/api/admin/customers-omde/query/reagent` | `queryReagents` (:82) | `@RequirePermission('persons.create')` (:81) | — |
| POST | `/api/admin/customers-omde/wallet/charge` | `chargeWallet` (:90) | `@HttpCode(200)`, `@RequirePermission('wallet')` (:88-89) | inline `{ customer_id, value }` |
| POST | `/api/admin/customers-omde/create` | `create` (:97) | `@HttpCode(200)`, `persons.create` (:95-96) | `Record<string, unknown>` |
| PUT | `/api/admin/customers-omde/:id` | `update` (:103) | `persons.list.client.omde.edit` (:102) | `Record<string, unknown>` |
| POST | `/api/admin/customers-omde/reagent/list` | `listReagents` (:111) | `@HttpCode(200)`, `persons.list.sellers` (:109-110) | `TableQueryDto` |
| GET | `/api/admin/customers-omde/reagent/:id` | `reagentDetail` (:118) | `persons.list.sellers.view` (:117) | — |
| GET | `/api/admin/customers-omde/:id/orders` | `customerOrders` (:125) | `persons.list.client.omde.view.orders` (:124) | — |
| GET | `/api/admin/customers-omde/:id/documents` | `customerDocuments` (:132) | `persons.list.client.omde.view.documents` (:131) | — |
| GET | `/api/admin/customers-omde/:id/wallet` | `customerWallet` (:139) | `@RequirePermission('wallet')` (:138) | — |
| GET | `/api/admin/customers-omde/dashboard/:id` | `dashboard` (:145) | `persons.list.client.omde.view` (:144) | — |
| GET | `/api/admin/customers-omde/archive/:id` | `toggleArchive` (:151) | `persons.list.client.omde.edit` (:150) | — |
| POST | `/api/admin/customers-omde/toggle-active` | `toggleActive` (:158) | `@HttpCode(200)`, `persons.list.client.omde.edit` (:156-157) | inline `{ id }` |
| POST | `/api/admin/customers-omde/toggle-sts` | `toggleSts` (:165) | `@HttpCode(200)`, `persons.list.client.omde.edit` (:163-164) | inline `{ id }` |
| POST | `/api/admin/customers-omde/verify` | `verify` (:172) | `@HttpCode(200)`, `persons.list.client.omde.edit` (:170-171) | inline `{ customer_id, sts_verify_step_one?, sts_verify_step_two? }` (:174) |
| POST | `/api/admin/customers-omde/credit` | `updateCredit` (:182) | `@HttpCode(200)`, `persons.list.client.omde.edit` (:180-181) | inline `{ id, gram_level?, percent? }` |
| GET | `/api/admin/customers-omde/:id` | `findOne` (:188) | `persons.list.client.omde.view` (:187) | — |
### 4.7 AdminCommentsController — `@Controller('admin/comments')` (admin-comments.controller.ts:15)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/comments/list` | `list` (:22) | `@HttpCode(200)`, `@RequirePermission('comment')` (:20-21) | `TableQueryDto` |
| GET | `/api/admin/comments/status-options` | `statusOptions` (:28) | `comment` (:27) | — |
| POST | `/api/admin/comments/sts` | `changeStatus` (:35) | `@HttpCode(200)`, `comment` (:33-34) | inline `{ id: string; sts: number }` |
| POST | `/api/admin/comments` | `create` (:42) | `@HttpCode(200)`, `comment` (:40-41) | inline `{ product_id, customer_id, comment, reply_to? }` (:43) |
| GET | `/api/admin/comments/:id` | `findOne` (:50) | `comment` (:49) | — |
| DELETE | `/api/admin/comments/:id` | `remove` (:56) | `comment` (:55) | — |
### 4.8 AdminProductsController — `@Controller('admin/products')` (admin-products.controller.ts:18)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/products/list` | `list` (:25) | `@HttpCode(200)`, `product.list` (:23-24) | `TableQueryDto` |
| POST | `/api/admin/products/variants/list` | `listVariants` (:32) | `@HttpCode(200)`, `product.list` (:30-31) | `TableQueryDto & { product_id? }` |
| POST | `/api/admin/products/by-ids` | `byIds` (:44) | `@HttpCode(200)`, `product.list` (:42-43) | inline `{ products_id: string[] }` |
| POST | `/api/admin/products/variants/create` | `createVariant` (:55) | `@HttpCode(200)`, **`order.create`** (:53-54) | inline `{ product_id, title, grams }` |
| GET | `/api/admin/products/show-type-options` | `showTypeOptions` (:61) | `product.list` (:60) | — |
| GET | `/api/admin/products/brands` | `brands` (:67) | `product.list` (:66) | — |
| POST | `/api/admin/products/brands-table` | `brandsTable` (:75) | `@HttpCode(200)`, `product.list` (:73-74) | `TableQueryDto` |
| GET | `/api/admin/products/collections` | `collections` (:82) | `product.list` (:81) | — |
| POST | `/api/admin/products/change-percent/count` | `changePercentCount` (:89) | `@HttpCode(200)`, `product.percent` (:87-88) | inline `{ brand_id?, category_id? }` |
| POST | `/api/admin/products/change-percent/apply` | `changePercent` (:96) | `@HttpCode(200)`, `product.percent` (:94-95) | inline `{ percent, brand_id?, category_id? }` |
| POST | `/api/admin/products/create` | `create` (:103) | `@HttpCode(200)`, `product.create` (:101-102) | `Record<string, unknown>` |
| POST | `/api/admin/products/delete/:id` | `remove` (:110) | `@HttpCode(200)`, `product.list.edit` (:108-109) | — |
| GET | `/api/admin/products/:id/variants` | `variantsOf` (:116) | `product.list` (:115) | — |
| GET | `/api/admin/products/:id` | `findOne` (:122) | `product.list` (:121) | — |
| PUT | `/api/admin/products/:id` | `update` (:128) | `product.list.edit` (:127) | `Record<string, unknown>` |
### 4.9 AdminCategoriesController — `@Controller('admin/categories')` (admin-categories.controller.ts:14)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/categories/list` | `list` (:21) | `@HttpCode(200)`, `collection.list` (:19-20) | `TableQueryDto` |
| GET | `/api/admin/categories/tree` | `tree` (:27) | `collection.list` (:26); `@Query('type')` | — |
| GET | `/api/admin/categories/type-options` | `typeOptions` (:33) | `collection.list` (:32) | — |
| POST | `/api/admin/categories/collections/list` | `listCollections` (:41) | `@HttpCode(200)`, `collection.list` (:39-40) | `TableQueryDto` |
| POST | `/api/admin/categories/collections` | `createCollection` (:48) | `@HttpCode(200)`, `collection.create` (:46-47) | inline `{ title, description?, is_active? }` |
| PUT | `/api/admin/categories/collections/:id` | `updateCollection` (:53) | `collection.list.edit` (:52) | inline `{ title?, description?, is_active? }` |
| DELETE | `/api/admin/categories/collections/:id` | `removeCollection` (:63) | `collection.list.edit` (:62) | — |
| POST | `/api/admin/categories` | `create` (:71) | `@HttpCode(200)`, `collection.create` (:69-70) | `Record<string, unknown>` |
| GET | `/api/admin/categories/:id` | `findOne` (:77) | `collection.list` (:76) | — |
| PUT | `/api/admin/categories/:id` | `update` (:83) | `collection.list.edit` (:82) | `Record<string, unknown>` |
| DELETE | `/api/admin/categories/:id` | `remove` (:89) | `collection.list.edit` (:88) | — |
### 4.10 AdminDocumentsController — `@Controller('admin/documents')` (admin-documents.controller.ts:17)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/documents/list` | `list` (:24) | `@HttpCode(200)`, `document.list` (:22-23) | `TableQueryDto` |
| GET | `/api/admin/documents/type-options` | `typeOptions` (:30) | `document.list` (:29) | — |
| GET | `/api/admin/documents/query/customer` | `queryCustomers` (:36) | `document.create` (:35) | — |
| POST | `/api/admin/documents/wallet/list` | `listWallet` (:43) | `@HttpCode(200)`, `wallet` (:41-42) | `TableQueryDto` |
| GET | `/api/admin/documents/wallet/config` | `getWalletConfig` (:50) | `config.wallet` (:49) | — |
| POST | `/api/admin/documents/wallet/config` | `setWalletConfig` (:57) | `@HttpCode(200)`, `config.wallet` (:55-56) | `Record<string, unknown>` |
| GET | `/api/admin/documents/wallet/birthday/config` | `getBirthdayConfig` (:63) | `config.wallet` (:62) | — |
| POST | `/api/admin/documents/wallet/birthday/config` | `setBirthdayConfig` (:70) | `@HttpCode(200)`, `config.wallet` (:68-69) | `Record<string, unknown>` |
| POST | `/api/admin/documents` | `create` (:77) | `@HttpCode(200)`, `document.create` (:75-76), `@CurrentAdmin()` | `Record<string, unknown>` |
| POST | `/api/admin/documents/:id/confirm` | `toggleConfirm` (:85) | `@HttpCode(200)`, `document.list.edit` (:83-84), `@CurrentAdmin()` | — |
| GET | `/api/admin/documents/:id` | `findOne` (:91) | `document.list` (:90) | — |
| PUT | `/api/admin/documents/:id` | `update` (:97) | `document.list.edit` (:96) | `Record<string, unknown>` |
| DELETE | `/api/admin/documents/:id` | `remove` (:103) | `document.list.edit` (:102) | — |
### 4.11 AdminPromotionsController — `@Controller('admin/promotions')` (admin-promotions.controller.ts:12)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/promotions/list` | `list` (:19) | `@HttpCode(200)`, `offers.list` (:17-18) | `TableQueryDto` |
| GET | `/api/admin/promotions/scope-options` | `scopeOptions` (:25) | `offers.list` (:24) | — |
| GET | `/api/admin/promotions/usage-options` | `usageOptions` (:31) | `offers.list` (:30) | — |
| GET | `/api/admin/promotions/auto` | `getAutoPromotion` (:37) | `offers.autopromotion` (:36) | — |
| POST | `/api/admin/promotions/auto` | `setAutoPromotion` (:44) | `@HttpCode(200)`, `offers.autopromotion` (:42-43) | inline `{ state?, discount_percent? }` |
| GET | `/api/admin/promotions/exclusions` | `listExclusions` (:51) | `offersIgnore` (:50) | — |
| POST | `/api/admin/promotions/exclusions` | `createExclusion` (:58) | `@HttpCode(200)`, `offersIgnore` (:56-57) | inline `{ type: number; item_id?: string[] }` |
| DELETE | `/api/admin/promotions/exclusions/:id` | `removeExclusion` (:64) | `offersIgnore` (:63) | — |
| POST | `/api/admin/promotions` | `create` (:72) | `@HttpCode(200)`, `offers.create` (:70-71) | `Record<string, unknown>` |
| GET | `/api/admin/promotions/:id` | `findOne` (:78) | `offers.list` (:77) | — |
| PUT | `/api/admin/promotions/:id` | `update` (:84) | `offers.list.edit` (:83) | `Record<string, unknown>` |
| DELETE | `/api/admin/promotions/:id` | `remove` (:90) | `offers.list.edit` (:89) | — |
### 4.12 AdminUsersController — `@Controller('admin/users')` (admin-users.controller.ts:16)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/users/list` | `list` (:23) | `@HttpCode(200)`, `user.list` (:21-22) | `TableQueryDto` |
| GET | `/api/admin/users/role-options` | `roleOptions` (:29) | `user.list` (:28) | — |
| GET | `/api/admin/users/types` | `listUserTypes` (:36) | `user.list` (:35) | — |
| POST | `/api/admin/users/types` | `createUserType` (:44) | `@HttpCode(200)`, `user.permission.create` (:42-43) | inline `{ val?, title? }` |
| GET | `/api/admin/users/types/:ut/roles` | `getRoles` (:51) | `user.permission` (:50) | — |
| POST | `/api/admin/users/roles` | `setRoles` (:59) | `@HttpCode(200)`, `user.permission.edit` (:57-58) | inline `{ ut, roles: string[] }` |
| DELETE | `/api/admin/users/types/:ut/roles/:rid` | `revokeRole` (:66) | `user.permission.edit` (:65) | — |
| GET | `/api/admin/users/me` | `myData` (:72) | auth only, **no rid**; `@CurrentAdmin()` (:71-72) | — |
| POST | `/api/admin/users` | `create` (:79) | `@HttpCode(200)`, `user.create` (:77-78) | `Record<string, unknown>` |
| GET | `/api/admin/users/:id` | `findOne` (:85) | `user.list` (:84) | — |
| PUT | `/api/admin/users/:id` | `update` (:91) | `user.list.edit` (:90) | `Record<string, unknown>` |
| DELETE | `/api/admin/users/:id` | `remove` (:97) | `user.list.remove` (:96), `@CurrentAdmin()` | — |
### 4.13 AdminBranchesController — `@Controller('admin/branches')` (admin-branches.controller.ts:14)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/branches/list` | `listBranches` (:22) | `@HttpCode(200)`, `branch.list` (:20-21) | `TableQueryDto` |
| GET | `/api/admin/branches/user-list` | `userList` (:28) | `branch.create` (:27) | — |
| POST | `/api/admin/branches` | `createBranch` (:35) | `@HttpCode(200)`, `branch.create` (:33-34) | `Record<string, unknown>` |
| GET | `/api/admin/branches/active/:id` | `toggleBranchActive` (:41) | `branch.list` (:40) | — |
| GET | `/api/admin/branches/:id` | `findBranch` (:47) | `branch.list` (:46) | — |
| PUT | `/api/admin/branches/:id` | `updateBranch` (:53) | `branch.list` (:52) | `Record<string, unknown>` |
### 4.14 AdminInvoicesController — `@Controller('admin/invoices')` (admin-invoices.controller.ts:15)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/invoices/list` | `list` (:22) | `@HttpCode(200)`, `invoice.list` (:20-21) | `TableQueryDto` |
| GET | `/api/admin/invoices/branch-list` | `branchList` (:28) | `invoice.create` (:27) | — |
| POST | `/api/admin/invoices` | `create` (:35) | `@HttpCode(200)`, `invoice.create` (:33-34), `@CurrentAdmin()` | inline `{ branch_id?, products?, note? }` |
| GET | `/api/admin/invoices/:id` | `findOne` (:44) | `invoice.list` (:43) | — |
| PUT | `/api/admin/invoices/:id` | `update` (:50) | `invoice.list` (:49) | inline `{ branch_id?, products?, note? }` |
| DELETE | `/api/admin/invoices/:id` | `remove` (:59) | `invoice.list` (:58) | — |
### 4.15 AdminMiscController — `@Controller('admin/misc')` (admin-misc.controller.ts:21)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/misc/cities/list` | `listCities` (:29) | `@HttpCode(200)`, `cities.list` (:27-28) | `TableQueryDto` |
| POST | `/api/admin/misc/cities` | `createCity` (:36) | `@HttpCode(200)`, `cities.create` (:34-35) | inline `{ title?, name?, province? }` |
| GET | `/api/admin/misc/cities/percent/:id` | `getCityPercent` (:42) | `cities.list` (:41) | — |
| GET | `/api/admin/misc/cities/percent-aux/:id` | `getCityPercentAux` (:48) | `cities.list` (:47) | — |
| PUT | `/api/admin/misc/cities/:id` | `updateCity` (:54) | `cities.list.view.edit` (:53) | `Record<string, never>` |
| DELETE | `/api/admin/misc/cities/:id` | `removeCity` (:60) | `cities.list.remove` (:59) | — |
| POST | `/api/admin/misc/sms/list` | `listSms` (:68) | `@HttpCode(200)`, `sms.list` (:66-67) | `TableQueryDto` |
| POST | `/api/admin/misc/sms` | `sendSms` (:75) | `@HttpCode(200)`, `sms.create` (:73-74), `@CurrentAdmin()` | inline `{ type?, mobile?, text? }` |
| POST | `/api/admin/misc/notify/list` | `listNotifications` (:86) | `@HttpCode(200)`, `notify.me` (:84-85) | `TableQueryDto` |
| POST | `/api/admin/misc/notify` | `createNotification` (:93) | `@HttpCode(200)`, `notify.create` (:91-92), `@CurrentAdmin()` | inline `{ title?, text?, body?, type? }` |
| POST | `/api/admin/misc/notify/customer` | `notifyCustomer` (:103) | `@HttpCode(200)`, `notify.create` (:101-102), `@CurrentAdmin()` | inline `{ customer_id?, title?, text?, body? }` |
| GET | `/api/admin/misc/notify/read/:id` | `markRead` (:112) | `notify.me` (:111) | — |
| GET | `/api/admin/misc/notify/:id` | `findNotification` (:118) | `notify.me` (:117) | — |
| POST | `/api/admin/misc/newsletter/list` | `listNewsletter` (:126) | `@HttpCode(200)`, `newsletters` (:124-125) | `TableQueryDto` |
| GET | `/api/admin/misc/log/customers` | `logCustomers` (:133) | `statics` (:132) | — |
| GET | `/api/admin/misc/log/customer/:id` | `customerLog` (:139) | `statics` (:138) | — |
| GET | `/api/admin/misc/dashboard-links` | `dashboardLinks` (:146) | `config.report` (:145) | — |
| GET | `/api/admin/misc/const` | `listConsts` (:152) | `config.statics` (:151) | — |
| POST | `/api/admin/misc/brands` | `createBrand` (:159) | `@HttpCode(200)`, `config.statics.brand.create` (:157-158) | inline `{ title }` |
| PUT | `/api/admin/misc/brands/:id` | `updateBrand` (:165) | `config.statics.brand.edit` (:164) | inline `{ title }` |
| DELETE | `/api/admin/misc/brands/:id` | `removeBrand` (:171) | `config.statics.brand.remove` (:170) | — |
| GET | `/api/admin/misc/const/slider` | `getSlider` (:186) | `custom.config.statics.slider` (:185) | — |
| PUT | `/api/admin/misc/const/slider` | `setSlider` (:192) | `custom.config.statics.slider` (:191) | `Record<string, unknown>` |
| DELETE | `/api/admin/misc/const/slider` | `removeSlider` (:198) | `custom.config.statics.slider` (:197) | — |
| GET | `/api/admin/misc/const/:key` | `getConst` (:204) | `config.statics` (:203); `assertNotSliderKey` (:205,229-233) | — |
| PUT | `/api/admin/misc/const/:key` | `setConst` (:211) | `config.statics` (:210); `assertNotSliderKey` | `Record<string, unknown>`, reads `body.val` fallback (:214) |
| DELETE | `/api/admin/misc/const/:key` | `removeConst` (:219) | `config.statics` (:218); `assertNotSliderKey` | — |
### 4.16 PublicSliderController — `@Public() @Controller('home')` (public-slider.controller.ts:16-17)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| GET | `/api/home/slider` | `getSlider` (:22) | `@Public()` class-level (:16) | — |
### 4.17 AdminUploadsController — `@Controller('admin/files')` (admin-uploads.controller.ts:45)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/admin/files/create` | `create` (:52) | `@HttpCode(200)`, `@UseInterceptors(FileInterceptor('file'))` (:50-51), `@UploadedFile()`, `@Body('purpose')`, `@CurrentAdmin()`; auth required, **no rid** (rationale :38-44) | multipart `file` + `purpose` (no DTO class) |
| GET | `/api/admin/files/:id` | `findOne` (:61) | auth only, **no rid** (:60) | — |
### 4.18 PublicUploadsController — `@Public() @Controller('uploads')` (public-uploads.controller.ts:14-15)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| GET | `/api/uploads/:filename` | `stream` (:19) | `@Public()` class-level (:14); `@Header('Cache-Control','public, max-age=86400')` (:18) | — |
### 4.19 AdminDashboardController — `@Controller('admin/dashboard')` (admin-dashboard.controller.ts:15)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| GET | `/api/admin/dashboard/overview` | `overview` (:21) | `dashboard.view` (:20) | — |
| GET | `/api/admin/dashboard/omde` | `omde` (:28) — deprecated alias | `dashboard.view` (:27) | — |
| GET | `/api/admin/dashboard/tak` | `tak` (:34) — deprecated alias | `dashboard.view` (:33) | — |
| GET | `/api/admin/dashboard/report/order-item` | `orderItemReport` (:42) | `report.order.item` (:41); `@Query('start_date'/'end_date')` | — |
| GET | `/api/admin/dashboard/report/product` | `productReport` (:49) | `report.product` (:48); `@Query(...)` | — |
### 4.20 CatalogController — `@Public() @Controller('catalog/products')` (catalog.controller.ts:5-6)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| GET | `/api/catalog/products` | `list` (:11) | `@Public()` class-level (:5) | — |
### 4.21 CustomersController — `@Public() @Controller('customers')` (customers.controller.ts:5-6)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| GET | `/api/customers` | `list` (:11) | `@Public()` class-level | — |
| GET | `/api/customers/:id` | `get` (:16) | `@Public()` | — |
| GET | `/api/customers/:id/orders` | `getOrders` (:21) | `@Public()` | — |
| GET | `/api/customers/:id/ledger-entries` | `getLedgerEntries` (:26) | `@Public()` | — |
### 4.22 SalesAgentController — `@Controller('sales-agent')` (sales-agent.controller.ts:21)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| GET | `/api/sales-agent/me` | `me` (:26) | auth required, **no rid**; identity-scoped in service (`role==='SALES_EXPERT'`, sales-agent.service.ts:36-40) | — |
| GET | `/api/sales-agent/my-customers` | `myCustomers` (:31) | auth, no rid; `@Query('search')` | — |
| POST | `/api/sales-agent/verify-acting-as` | `verifyActingAs` (:37) | `@HttpCode(200)` (:36); auth, no rid | inline `{ customerId }` |
### 4.23 OrdersController — `@Public() @Controller('orders')` (orders.controller.ts:7-8)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| POST | `/api/orders` | `create` (:13) | `@Public()` class-level | `CreateOrderDto` |
| GET | `/api/orders/:id` | `get` (:18) | `@Public()` | — |
| PATCH | `/api/orders/:id/status` | `updateStatus` (:23) | `@Public()` | `UpdateOrderStatusDto` (orders/dto) |
### 4.24 AccountingController — `@Public() @Controller('accounting')` (accounting.controller.ts:7-8)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| GET | `/api/accounting/customers/:customerId/balance` | `getBalance` (:13) | `@Public()` class-level | — |
| POST | `/api/accounting/customers/:customerId/reconcile` | `reconcile` (:18) | `@Public()` | — |
| POST | `/api/accounting/repayments/gold` | `postGoldRepayment` (:23) | `@Public()` | `PostGoldRepaymentDto` |
| POST | `/api/accounting/repayments/rial` | `postRialRepayment` (:28) | `@Public()` | `PostRialRepaymentDto` |
| POST | `/api/accounting/entries/:entryId/confirm` | `confirmEntry` (:33) | `@Public()` | — |
### 4.25 PricingController — `@Public() @Controller('pricing')` (pricing.controller.ts:5-6)
| METHOD | Full path | Handler | Guards/decorators | DTO |
|---|---|---|---|---|
| GET | `/api/pricing/latest` | `getLatest` (:11) | `@Public()`; `@Query('karatPerMille')`, defaults 750 (:12) | — |
### 4.26 Endpoint totals
| Controller | Routes |
|---|---|
| AuthController | 2 |
| AdminOrdersController | 9 |
| AdminCitiesController | 1 |
| AdminPreOrdersController | 5 |
| AdminWorkshopsController | 5 |
| AdminCustomersController | 23 |
| AdminCommentsController | 6 |
| AdminProductsController | 15 |
| AdminCategoriesController | 11 |
| AdminDocumentsController | 13 |
| AdminPromotionsController | 12 |
| AdminUsersController | 12 |
| AdminBranchesController | 6 |
| AdminInvoicesController | 6 |
| AdminMiscController | 27 |
| PublicSliderController | 1 |
| AdminUploadsController | 2 |
| PublicUploadsController | 1 |
| AdminDashboardController | 5 |
| CatalogController | 1 |
| CustomersController | 4 |
| SalesAgentController | 3 |
| OrdersController | 3 |
| AccountingController | 5 |
| PricingController | 1 |
| **TOTAL** | **179** |
**Unauthenticated (`@Public()`) surface — 15 routes**: all of AccountingController (5), OrdersController (3), CustomersController (4), CatalogController (1), PricingController (1), PublicSliderController (1), PublicUploadsController (1). Note `/api/accounting/*` writes to the ledger and `/api/orders` creates gold-debt-bearing orders while being fully public (accounting.controller.ts:7, orders.controller.ts:7).
---
## 5. DTOs (every class, fields, class-validator rules)
Only **6 DTO files / 8 DTO classes** exist. Most admin routes take untyped `Record<string, unknown>` or inline object literals (no validation beyond the global `whitelist` pipe, which strips nothing on a plain `Record` type).
### `src/modules/auth/dto/login.dto.ts` — `LoginDto`
| Field | Type | Validators | Cite |
|---|---|---|---|
| `phone` | string | `@IsString()`, `@IsNotEmpty()` | :11-13 |
| `password` | string | `@IsString()`, `@IsNotEmpty()` | :15-17 |
### `src/modules/accounting/dto/post-gold-repayment.dto.ts` — `PostGoldRepaymentDto`
| Field | Type | Validators | Cite |
|---|---|---|---|
| `customerId` | string | `@IsString()` | :4-5 |
| `instrument` | `'MELTED_GOLD'\|'TRANSFER'\|'CHEQUE'\|'CASH'` | `@IsIn([...])` | :7-8 |
| `rawValue` | number | `@IsNumber()`, `@IsPositive()` | :10-12 |
| `karatPerMille?` | number | `@IsOptional()`, `@IsNumber()` | :14-16 |
| `pricePerGramRial?` | number | `@IsOptional()`, `@IsNumber()`, `@IsPositive()` | :18-21 |
| `note?` | string | `@IsOptional()`, `@IsString()` | :23-25 |
### `src/modules/accounting/dto/post-rial-repayment.dto.ts` — `PostRialRepaymentDto`
| Field | Type | Validators | Cite |
|---|---|---|---|
| `customerId` | string | `@IsString()` | :4-5 |
| `amount` | number | `@IsNumber()`, `@IsPositive()` | :7-9 |
| `note?` | string | `@IsOptional()`, `@IsString()` | :11-13 |
### `src/modules/orders/dto/create-order.dto.ts` — `CreateOrderItemInputDto`
| Field | Type | Validators | Cite |
|---|---|---|---|
| `productVariantId` | string | `@IsString()` | :6-7 |
| `quantity` | number | `@IsInt()`, `@Min(1)` | :9-11 |
| `wagePercent` | number | `@IsNumber()` (no min/max — negative or >100 accepted) | :14-15 |
| `discountPercent?` | number | `@IsOptional()`, `@IsNumber()` | :17-19 |
| `flatRialCharge?` | number | `@IsOptional()`, `@IsNumber()` | :22-24 |
### `src/modules/orders/dto/create-order.dto.ts` — `CreateOrderDto`
| Field | Type | Validators | Cite |
|---|---|---|---|
| `customerId` | string | `@IsString()` | :28-29 |
| `branchId?` | string | `@IsOptional()`, `@IsString()` | :31-33 |
| `channel?` | `OrderChannel` | `@IsOptional()`, `@IsEnum(OrderChannel)`; defaults RETAIL in service | :40-42 |
| `items` | `CreateOrderItemInputDto[]` | `@IsArray()`, `@ArrayMinSize(1)`, `@ValidateNested({each:true})`, `@Type(()=>CreateOrderItemInputDto)` | :44-48 |
### `src/modules/orders/dto/update-order-status.dto.ts` — `UpdateOrderStatusDto`
| Field | Type | Validators | Cite |
|---|---|---|---|
| `status` | `OrderStatus` | `@IsEnum(OrderStatus)` | :5-6 |
| `note?` | string | `@IsOptional()`, `@IsString()` | :8-10 |
| `actorAdminUserId?` | string | `@IsOptional()`, `@IsString()` — **client-supplied actor id on a `@Public()` route** | :12-14 |
### `src/modules/admin-orders/update-status.dto.ts` — `UpdateOrderStatusDto` (distinct class, same name)
| Field | Type | Validators | Cite |
|---|---|---|---|
| `orderId` | string | `@IsString()` | :12-13 |
| `status` | `OrderStatus` | `@IsEnum(OrderStatus)` | :15-16 |
| `note?` | string | `@IsOptional()`, `@IsString()` | :18-20 |
| `remove?` | boolean | `@IsOptional()`, `@IsBoolean()` — true = delete that status event | :23-25 |
### `src/modules/admin-table/table-query.dto.ts` — `TableFilterDto`
| Field | Type | Validators | Cite |
|---|---|---|---|
| `id?` | number\|string | `@IsOptional()` (accepted, ignored) | :22-23 |
| `field?` | string | `@IsOptional()`, `@IsString()` | :25-27 |
| `op?` | string | `@IsOptional()`, `@IsIn(['=','!=','>','>=','<','<=','like','in','between','null','notnull'])` | :32-34 |
| `value?` | unknown | `@IsOptional()` | :36-37 |
| `value2?` | unknown | `@IsOptional()` (used by `between`) | :39-40 |
### `src/modules/admin-table/table-query.dto.ts` — `TableQueryDto`
| Field | Type | Validators | Cite |
|---|---|---|---|
| `search?` | string | `@IsOptional()`, `@IsString()` | :51-53 |
| `filters?` | `TableFilterDto[]` | `@IsOptional()`, `@IsArray()`, `@ValidateNested({each:true})`, `@Type(()=>TableFilterDto)` | :55-59 |
| `page?` | number | `@IsOptional()`, `@Type(()=>Number)`, `@IsInt()`, `@Min(1)` | :61-65 |
| `limit?` | number | `@IsOptional()`, `@Type(()=>Number)`, `@IsInt()`, `@Min(1)`, `@Max(500)` | :67-72 |
| `order?` | string | `@IsOptional()`, `@IsString()` — `'+field'` asc / `'-field'` desc | :75-77 |
| `select?` | string[] | `@IsOptional()`, `@IsArray()` | :80-82 |
| `titles?` | `Record<string,string>` | `@IsOptional()` | :85-86 |
| `report_title?` | string | `@IsOptional()`, `@IsString()` | :88-90 |
| `op?` | `'excel'\|'json'\|'print'` | `@IsOptional()`, `@IsIn([...])` — always rejected by `assertNotExport` | :97-99 |
| `total?` | number | `@IsOptional()` (echoed back) | :102-103 |
---
## 6. Prisma schema (`prisma/schema.prisma`, 911 lines)
Generator: `prisma-client-js`, `binaryTargets = ["native","debian-openssl-3.0.x"]` (schema.prisma:6-10).
Datasource: `postgresql`, url from `env("DATABASE_URL")` (schema.prisma:12-15).
Header note: *"All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math"* (schema.prisma:4).
### 6.1 Enums (14)
| Enum | Values | Cite |
|---|---|---|
| `AdminRole` | SUPER_ADMIN, SYSTEM_APPROVER, SALES_EXPERT, WORKSHOP_SUPERVISOR, ACCOUNTANT | :21-27 |
| `CustomerKind` | SALES_AGENT (legacy-only), APP_SHOPKEEPER, WALK_IN_SHOPKEEPER, BANKER, WORKSHOP_SUPERVISOR, RETAIL | :45-52 |
| `VerificationStep` | UNVERIFIED, STEP_ONE_SUBMITTED, STEP_TWO_SUBMITTED, VERIFIED | :54-59 |
| `OrderChannel` | RETAIL, WHOLESALE | :64-67 |
| `OrderStatus` | SUSPENDED, PLACED, ADMIN_APPROVED, SOURCING, PREPARING, DELIVERED_TO_AGENT, DELIVERED_TO_CUSTOMER, CANCELLED | :69-78 |
| `PreOrderStatus` | PLACED, ADMIN_APPROVED, WORKSHOP_APPROVED, READY_TO_SHIP, DELIVERED, CANCELLED | :80-87 |
| `LedgerCurrency` | GOLD_GRAM, RIAL | :89-92 |
| `LedgerDirection` | DEBIT (increases owed), CREDIT (decreases owed) | :94-97 |
| `LedgerSourceType` | ORDER_STOCK, ORDER_PRE_ORDER, REPAYMENT_MELTED_GOLD, REPAYMENT_TRANSFER, REPAYMENT_CHEQUE, REPAYMENT_CASH, WALLET_BONUS, ADJUSTMENT | :99-108 |
| `ProductImageRole` | FEATURED, FEATURED_MOBILE, GALLERY, GALLERY_MOBILE | :367-372 |
| `CommentStatus` | PENDING, APPROVED, REJECTED | :655-659 |
| `PromotionScope` | PRODUCT, CATEGORY, CUSTOMER, BRAND | :704-709 |
| `PromotionUsage` | ONCE, UNTIL_END_DATE | :712-715 |
| `MessageAudience` | SINGLE_NUMBER, MULTIPLE_NUMBERS, ALL_CUSTOMERS, WHOLESALE_CUSTOMERS, RETAIL_CUSTOMERS, SALES_AGENTS | :805-812 |
### 6.2 Models (29)
#### `City` (:114-126)
| Field | Type | Default / Attrs |
|---|---|---|
| id | String | `@id @default(cuid())` |
| name | String | — |
| province | String? | — |
| percent | Decimal? | `@db.Decimal(6,3)` — per-city wage surcharge (:120) |
| percentAux | Decimal? | `@db.Decimal(6,3)` (:121) |
| customers/addresses/branches/preOrders | relations | Customer[], CustomerAddress[], Branch[], PreOrder[] (:122-125) |
#### `AdminUser` (:128-170)
| Field | Type | Default / Attrs |
|---|---|---|
| id | String | `@id @default(cuid())` |
| fullName | String | — |
| mobile | String | **`@unique`** (:131) |
| passwordHash | String | — |
| role | AdminRole | — |
| userTypeId | Int? | FK → UserType (:137-138) |
| branchId | String? | FK → Branch (:139-140) |
| wagePercent | Decimal? | `@db.Decimal(6,3)` — commission on referred orders (:141) |
| isActive | Boolean | `@default(true)` (:142) |
| createdAt | DateTime | `@default(now())` (:143) |
| avatarFileId | String? | FK → UploadedFile (relation `AdminUserAvatar`) (:150-151) |
| address, storeName, pasazhName, memberCode | String? | legacy sales-agent fields (:152-155) |
| Relations | referredCustomers (`ReferralAgent`), supervisedWorkshops, branchAllocations, postedLedgerEntries, orderStatusEvents, preOrderStatusEvents, sentSmsMessages, sentNotifications, uploadedFiles | :157-165 |
| Indexes | `@@index([role])`, `@@index([userTypeId])`, `@@index([avatarFileId])` | :167-169 |
#### `Customer` (:172-238)
| Field | Type | Default / Attrs |
|---|---|---|
| id | String | `@id @default(cuid())` |
| fullName | String | — |
| mobile | String | **`@unique`** (:175) |
| nationalCode | String | **`@unique`** (:176) |
| passwordHash | String | — |
| cityId / city | String? / City? | (:178-179) |
| kind | CustomerKind | `@default(APP_SHOPKEEPER)` (:183) |
| phone, address, storeName, pasazhName, zone, memberCode | String? | (:187-193) |
| referredByAgentId | String? | FK → AdminUser (`ReferralAgent`) (:195-196) |
| **goldCreditLimitGrams** | Decimal | `@default(0) @db.Decimal(14,4)` — legacy `gram_level` (:200) |
| **goldDebtGrams** | Decimal | `@default(0) @db.Decimal(14,4)` — legacy `remain` (cache) (:201) |
| **fiatDebtRial** | Decimal | `@default(0) @db.Decimal(20,2)` — legacy `rial_remain` (cache) (:202) |
| verificationStep | VerificationStep | `@default(UNVERIFIED)` (:204) |
| nationalIdImageUrl, businessLicenseUrl, shopImageUrl | String? | KYC docs (:205-207) |
| avatarFileId | String? | FK → UploadedFile (`CustomerAvatar`) (:212-213) |
| canPlaceOrders | Boolean | `@default(true)` — legacy `sts` (:219) |
| isActive | Boolean | `@default(true)` — legacy `active` (:221) |
| isArchived | Boolean | `@default(false)` (:223) |
| createdAt / updatedAt | DateTime | `@default(now())` / `@updatedAt` (:224-225) |
| Relations | addresses, orders, preOrders, ledgerEntries, walletBonuses, comments, promotions, notifications, activityLogs | :227-235 |
| Index | `@@index([referredByAgentId])` | :237 |
#### `CustomerAddress` (:240-248)
id (cuid PK), customerId→Customer, cityId?→City, addressLine String, isDefault Boolean `@default(false)`.
#### `Branch` (:250-263)
id (cuid PK), name, address?, phone?, isActive `@default(true)` (:257), cityId?→City, relations adminUsers/orders/allocations.
#### `Workshop` (:265-276)
id (cuid PK), name, address?, phone?, supervisorId?→AdminUser (`WorkshopSupervisor`), isActive `@default(true)`, preOrders.
#### `Category` (:282-299)
id (cuid PK), title, type Int `@default(1)` (1=site,2=mobile), imageUrl?, iconUrl?, metaTag?, description?, parentId?→self (`CategoryTree`), children, products, promotions.
#### `Brand` (:301-306)
id (cuid PK), title, products, promotions.
#### `Collection` (:310-316)
id (cuid PK), title, description?, isActive `@default(true)`, products (CollectionProduct[]).
#### `CollectionProduct` (:318-325)
collectionId→Collection, productId→Product; **`@@id([collectionId, productId])`** (:324).
#### `Product` (:327-348)
id (cuid PK), title, description?, categoryId?→Category, brandId?→Brand, productCode String? **`@unique`** (:336), showType Int `@default(3)` (1 site/2 mobile/3 both) (:338), isActive `@default(true)`, createdAt `@default(now())`; relations variants, images, collections, comments, promotions, allocationItems.
#### `ProductImage` (:350-365)
id (cuid PK), productId→Product, url String, sortOrder Int `@default(0)`, role ProductImageRole `@default(GALLERY)` (:359), variants ProductVariant[].
#### `ProductVariant` (:374-402)
| Field | Type | Default / Attrs |
|---|---|---|
| id | String | cuid PK |
| productId | String | → Product |
| title | String | — |
| sku | String? | **`@unique`** (:379) |
| **rashWeightGrams** | Decimal | `@db.Decimal(14,4)` — raw weight before making charge (:381) |
| **defaultWagePercent** | Decimal | `@db.Decimal(6,3)` (:383) |
| qty | Int | `@default(0)` (:387) |
| isPreOrder | Boolean | `@default(false)` (:389) |
| isActive | Boolean | `@default(true)` (:390) |
| imageId | String? | → ProductImage, **`onDelete: SetNull`** (:396-397) |
| Index | `@@index([imageId])` | :401 |
#### `Order` (:408-439)
| Field | Type | Default / Attrs |
|---|---|---|
| id | String | cuid PK |
| orderNumber | String | **`@unique`** (:410) |
| customerId | String | → Customer (:411-412) |
| branchId | String? | → Branch (:413-414) |
| channel | OrderChannel | `@default(RETAIL)` (:416) |
| status | OrderStatus | `@default(PLACED)` (:417) |
| **totalGoldWeightGrams** | Decimal | `@db.Decimal(14,4)` — incl. wage (:420) |
| **wageWeightGrams** | Decimal | `@db.Decimal(14,4)` — pure making-charge component (:422) |
| **flatRialCharge** | Decimal | `@default(0) @db.Decimal(20,2)` — legacy `rial_input` (:424) |
| isSuspendedOverCreditLimit | Boolean | `@default(false)` (:426) |
| deliveredAt | DateTime? | setting it triggers debt recognition (:427) |
| createdAt/updatedAt | DateTime | `@default(now())` / `@updatedAt` (:429-430) |
| Relations | items, statusEvents, ledgerEntries, convertedFromPreOrder | :432-435 |
| Indexes | `@@index([customerId])`, `@@index([status])` | :437-438 |
#### `OrderItem` (:441-456)
| Field | Type | Default / Attrs |
|---|---|---|
| id | String | cuid PK |
| orderId / productVariantId | String | → Order / ProductVariant |
| quantity | Int | — |
| rashWeightGramsSnapshot | Decimal | `@db.Decimal(14,4)` (:450) |
| wagePercentSnapshot | Decimal | `@db.Decimal(6,3)` (:451) |
| discountPercentSnapshot | Decimal | `@default(0) @db.Decimal(6,3)` (:452) |
| computedWeightGrams | Decimal | `@db.Decimal(14,4)` = `rash*(1+(wage-discount)/100)` (:453-454) |
| flatRialCharge | Decimal | `@default(0) @db.Decimal(20,2)` (:455) |
#### `OrderStatusEvent` (:458-470)
id (cuid PK), orderId→Order, fromStatus OrderStatus?, toStatus OrderStatus, note?, actorAdminUserId?→AdminUser, createdAt `@default(now())`; `@@index([orderId])` (:469).
#### `PreOrder` (:476-505)
id (cuid PK), customerId→Customer, cityId?→City, workshopId?→Workshop, status PreOrderStatus `@default(PLACED)` (:484), designImageUrl?, colorName?, sizeSpec?, stoneColor?, minaColor?, estimatedWeightGrams Decimal? `@db.Decimal(14,4)` (:491), estimatedPriceRial Decimal? `@db.Decimal(20,2)` (:492), description?, convertedOrderId String? **`@unique`** →Order (:496-497), createdAt/updatedAt, statusEvents; `@@index([customerId])` (:504).
#### `PreOrderStatusEvent` (:507-519)
id (cuid PK), preOrderId→PreOrder, **changes Json** (field-level diff, :512), actorAdminUserId?→AdminUser, ipAddress?, createdAt; `@@index([preOrderId])` (:518).
#### `LedgerEntry` (:525-571) — core accounting row
| Field | Type | Default / Attrs |
|---|---|---|
| id | String | cuid PK |
| customerId | String | → Customer (:527-528) |
| currency | LedgerCurrency | (:530) |
| direction | LedgerDirection | (:531) |
| sourceType | LedgerSourceType | (:532) |
| **amount** | Decimal | `@db.Decimal(20,4)` — always positive; sign from `direction` (:533-534) |
| karatPerMille | Int? | purity for melted gold (:537) |
| pricePerGramRialAtEntry | Decimal? | `@db.Decimal(20,2)` — for CASH (:539) |
| orderId | String? | → Order (:541-542) |
| **confirmed** | Boolean | `@default(false)` — pending entries excluded from cached balance (:545) |
| confirmedAt | DateTime? | (:546) |
| postedByAdminUserId | String? | → AdminUser (:547-548) |
| labName, hallmark, accountName, bankName, transferTo | String? | instrument metadata (:555-559) |
| originalValue | Decimal? | `@db.Decimal(14,4)` — pre-karat-normalization weight (:561) |
| priceRial | Decimal? | `@db.Decimal(20,2)` (:563) |
| receivedAt | DateTime? | (:564) |
| note | String? | (:566) |
| createdAt | DateTime | `@default(now())` (:567) |
| Indexes | `@@index([customerId, currency])`, `@@index([orderId])` | :569-570 |
#### `GoldPriceQuote` (:574-582)
id (cuid PK), karatPerMille Int, **pricePerGramRial Decimal `@db.Decimal(20,2)`** (:577), source String, quotedAt DateTime `@default(now())` (:579); `@@index([karatPerMille, quotedAt])` (:581).
#### `WalletBonus` (:585-597)
id (cuid PK), customerId→Customer, orderId String? (**no FK relation declared**, :589), goldGrams Decimal `@db.Decimal(14,4)` (:590), grantedAt `@default(now())`, **expiresAt DateTime (required)** (:592), consumedAt DateTime?, isActive Boolean `@default(true)`; `@@index([customerId, isActive])` (:596).
#### `UserType` (:620-632)
id **Int** `@id` (legacy `ut`, not cuid) (:623), title String, isActive `@default(true)`, createdAt `@default(now())`; relations permissions, adminUsers; **`@@map("user_type")`** (:631).
#### `Permission` (:634-644)
rid String (dotted id), userTypeId Int → UserType **`onDelete: Cascade`** (:638); **`@@id([rid, userTypeId])`** composite PK (:641), `@@index([userTypeId])` (:642), **`@@map("permission")`** (:643).
#### `Comment` (:661-681)
id (cuid PK), productId→Product, customerId→Customer, body String, status CommentStatus `@default(PENDING)` (:668), replyToId?→self (`CommentReplies`), replies, createdAt/updatedAt; `@@index([productId])`, `@@index([customerId])`, `@@index([status])` (:678-680).
#### `Setting` (:690-694)
**key String `@id`**, value **Json**, updatedAt `@updatedAt`. Replaces legacy `config_static`.
#### `Promotion` (:717-746)
id (cuid PK), title, description?, scope PromotionScope `@default(PRODUCT)` (:721), usage PromotionUsage `@default(ONCE)` (:722), **discountPercent Decimal `@db.Decimal(6,3)`** (:724), isActive `@default(true)`, platform Int `@default(3)` (:727), startsAt?/endsAt?, and exactly one of productId/categoryId/customerId/brandId per scope (:733-740), createdAt/updatedAt; `@@index([scope, isActive])` (:745).
#### `PromotionExclusion` (:752-760)
id (cuid PK), type Int, itemId String, createdAt; **`@@unique([type, itemId])`** (:758), `@@index([type])` (:759).
#### `BranchAllocation` (:769-784)
id (cuid PK), branchId→Branch, allocatorAdminUserId?→AdminUser (`AllocationAuthor`), note?, createdAt, items; `@@index([branchId])` (:783).
#### `BranchAllocationItem` (:786-796)
id (cuid PK), allocationId→BranchAllocation **`onDelete: Cascade`** (:789), productId→Product, weightGrams Decimal? `@db.Decimal(14,4)` (:793); **`@@unique([allocationId, productId])`** (:795).
#### `SmsMessage` (:814-829)
id (cuid PK), audience MessageAudience, recipients String? (comma-separated), body String, sentCount Int `@default(0)` (:821), sentByAdminUserId?→AdminUser (`SmsAuthor`), createdAt; `@@index([createdAt])` (:828).
#### `Notification` (:831-845)
id (cuid PK), title, body, audience MessageAudience `@default(ALL_CUSTOMERS)` (:835), sentByAdminUserId?→AdminUser (`NotificationAuthor`), createdAt, recipients; `@@index([createdAt])` (:844).
#### `NotificationRecipient` (:848-858)
id (cuid PK), notificationId→Notification **`onDelete: Cascade`** (:851), customerId→Customer, readAt DateTime?; **`@@unique([notificationId, customerId])`** (:856), `@@index([customerId, readAt])` (:857).
#### `NewsletterSubscriber` (:861-869)
id (cuid PK), email String?, mobile String?, createdAt; **`@@unique([email])`**, **`@@unique([mobile])`** (:867-868).
#### `CustomerActivityLog` (:873-883)
id (cuid PK), customerId→Customer, action String (free-form key), detail String?, createdAt; `@@index([customerId, createdAt])` (:882).
#### `UploadedFile` (:892-911)
id (cuid PK), url String, originalName, mimeType, sizeBytes Int, purpose String?, uploadedByAdminUserId?→AdminUser (`FileUploader`), createdAt, customerAvatars/adminUserAvatars back-relations; `@@index([purpose])` (:910).
### 6.3 Decimal precision map (no `Float` anywhere in the schema)
| Precision | Used for | Examples |
|---|---|---|
| `Decimal(14,4)` | gold weights in grams | Customer.goldCreditLimitGrams/goldDebtGrams (:200-201), Order.totalGoldWeightGrams/wageWeightGrams (:420,422), OrderItem.rash/computedWeight (:450,454), ProductVariant.rashWeightGrams (:381), WalletBonus.goldGrams (:590), LedgerEntry.originalValue (:561), PreOrder.estimatedWeightGrams (:491), BranchAllocationItem.weightGrams (:793) |
| `Decimal(20,2)` | Rial money | Customer.fiatDebtRial (:202), Order.flatRialCharge (:424), OrderItem.flatRialCharge (:455), GoldPriceQuote.pricePerGramRial (:577), LedgerEntry.pricePerGramRialAtEntry/priceRial (:539,563), PreOrder.estimatedPriceRial (:492) |
| `Decimal(20,4)` | ledger amount (dual-currency, so finer than money) | LedgerEntry.amount (:534) |
| `Decimal(6,3)` | percentages | City.percent/percentAux (:120-121), AdminUser.wagePercent (:141), ProductVariant.defaultWagePercent (:383), OrderItem.wagePercentSnapshot/discountPercentSnapshot (:451-452), Promotion.discountPercent (:724) |
---
## 7. Migrations & seed
### 7.1 `prisma/migrations/*` (in order)
| # | Folder / file | Note |
|---|---|---|
| — | `0002_admin_schema.sql` | bare `.sql` at the migrations root (not a folder) — sorts first lexically |
| 1 | `20260910113720_first` | initial schema |
| 2 | `20260914165255_sync_schema_drift` | |
| 3 | `20260916141751_add_retail_customer_kind` | adds `CustomerKind.RETAIL` |
| 4 | `20260917120000_customer_name_parts_and_percent` | |
| 5 | `20260918090000_customer_parent_agent_self_relation` | |
| 6 | `20260918120000_add_product_image_role` | adds `ProductImageRole` |
| 7 | `20260921090000_add_variant_image` | adds `ProductVariant.imageId` |
| 8 | `20260921120000_agent_is_admin_user` | sales agent moves Customer→AdminUser |
| — | `migration_lock.toml` | `provider = "postgresql"` |
**Custom migration runner** (not `prisma migrate deploy`): `src/prisma/migrate.ts` applies the `.sql` files itself through the Prisma client and records them in a tracking table `_goldbank_migrations` (migrate.ts:5,7-13). Reason: the release image is copy-only and has no Prisma CLI (migrate.ts:10-12). Migrations dir resolved from `MIGRATIONS_DIR` or one of 4 fallbacks (migrate.ts:20-36). SQL is split statement-by-statement because Postgres rejects multi-statement parameterised queries (migrate.ts:38-42, `splitSqlStatements`). Invoked from `prepareDatabase()` before Nest boots (bootstrap.ts:104-134, main.ts:19).
### 7.2 `prisma/seed.ts` (738 lines) — what it creates
| Entity | What | Cite |
|---|---|---|
| Dev passwords | `DEV_PASSWORD = 'admin1234'`, bcrypt cost 10; `DEV_TEST_PHONE = '09128081392'` whose password **is the phone number itself** (dev-only) | seed.ts:16-24 |
| Cities (4) | تهران / مشهد / اصفهان / تبریز, fixed ids `city-tehran` etc. | :33-38 |
| Branches (2) | Tehran, Mashhad | :41-49 |
| Workshop (1) | upsert | :51 |
| **UserTypes + Permissions (5)** | see table below | :66-110 |
| AdminUsers (6 upserts) | dev test user `09128081392` (SUPER_ADMIN, ut 85, branchId **null**); `09120000001` مدیر ارشد سیستم (SUPER_ADMIN, ut 85, branchId null — deliberately branch-less so head office is not branch-scoped, :140-143); `09121234567` رضا احمدی (SALES_EXPERT, ut 86, Tehran, wagePercent 1.5); `09129876543` سارا کریمی (SALES_EXPERT, ut 86, Mashhad, wagePercent 1.2); `09123334444` هومن یزدانی (WORKSHOP_SUPERVISOR, ut 88); `09125556666` فرزانه محمودی (ACCOUNTANT, ut 87, branchId null) | :118-194 |
| Customers | `customerData` array, upserted with `PLACEHOLDER_PASSWORD_HASH` | :197-333 |
| Categories (5) | انگشتر، دستبند، گردنبند، گوشواره، سکه و شمش | :341-347 |
| Brands (2) | Montzar, Artisan | :349-358 |
| Products + variants + images | from `productDefs`; placeholder image `/img/no-items.png` | :29, 360-462 |
| Collection | `featured` + 3 CollectionProduct rows | :465-484 |
| **GoldPriceQuote** | `{ karatPerMille: 750, pricePerGramRial: '61500000', source: 'seed-illustrative' }` — **`create`, not upsert**, so a re-seed appends another quote | :487-489 |
| Order `ORD-SEED-0002` | RETAIL, status PLACED, rash 2.400, wage 7.5% → computed via **`parseFloat(...).toFixed(4)`** (float math in the seed only) | :494-527 |
| Order `ORD-SEED-0001` | WHOLESALE, DELIVERED_TO_CUSTOMER, rash 3.100, wage 9%, flatRialCharge 500000, `deliveredAt: now`, 3 status events | :542-580 |
| Ledger entries | GOLD_GRAM DEBIT ORDER_STOCK (confirmed) + RIAL DEBIT ORDER_STOCK (confirmed) + a **pending** GOLD_GRAM CREDIT REPAYMENT_MELTED_GOLD 1.5000 @ 750‰ (`confirmed: false`) to demo the confirm flow | :582-625 |
| Cached balances | `customer1` incremented by the posted debt (:608-611); `customer2` set to goldDebt 8.2500 / fiat 2000000; `customer5` zeroed | :608-630 |
| PreOrders (2) | `preorder-0001` PLACED, `preorder-0002` ADMIN_APPROVED | :635-667 |
| Comments (3) | one APPROVED, one PENDING, one REJECTED | :672-700 |
| Variant stock | every variant gets `qty = 5 + (i%4)`, `isPreOrder = i%5===0` | :705-711 |
| Product codes | `P-0001`, `P-0002`, … by title asc | :712-718 |
| **Setting rows** | **NONE** — the seed creates no `Setting` rows at all. `wallet.config`, `wallet.birthday`, `promotion.auto`, `mobile_main_slider` are created lazily on first write (admin-documents.service.ts:421-428, admin-promotions.service.ts:261-282) and read back as `{}` / defaults until then (admin-documents.service.ts:416-419, admin-promotions.service.ts:256-259) |
Seed UserTypes and their rids (seed.ts:66-98):
| ut id | Title | rids |
|---|---|---|
| 85 | مدیر ارشد | **`ALL_RIDS`** — all 144 (seed.ts:68) |
| 86 | کارشناس فروش | order.list, order.create, order.omde, order.makeOrder, persons.list.client.omde, persons.list.client.omde.view, persons.list.client.single, persons.list.client.single.view, persons.create, product.list, dashboard.view, comment (12) (:72-77) |
| 87 | حسابدار | document.list, document.create, document.list.edit, invoice.list, invoice.create, wallet, config.wallet, report.order.item, report.product, config.report, dashboard.view (11) (:82-86) |
| 88 | سرپرست کارگاه | workshop.list, workshop.create, order.tamin, order.list, product.list (5) (:91) |
| 89 | کاربر شعبه | buy.order.list, order.create, persons.create, branch.list (4) (:96) |
Only **leaf** rids are stored; ancestors derived at runtime (seed.ts:64-65). Permissions written with `createMany({ skipDuplicates: true })` (:106-109).
Idempotency: everything uses `upsert` except `goldPriceQuote.create` (:487) and the ledger/order blocks, which are guarded by a `findUnique` on the seed order number (:494-497, :542-543).
---
## 8. Business logic
### 8.1 Gold price
- `PricingService.recordQuote(karatPerMille, pricePerGramRial, source)` → inserts a `GoldPriceQuote` (pricing.service.ts:9-13). **No caller anywhere in `src/`** — the only write path is unused; quotes only enter via the seed.
- `PricingService.getLatestQuote(karat)` → newest quote for that karat, 404 if none (pricing.service.ts:15-22).
- `GET /api/pricing/latest?karatPerMille=` defaults to **750** (pricing.controller.ts:11-12).
- **No external price feed, no scheduler, no HTTP fetch** — nothing refreshes quotes automatically.
### 8.2 Gram / weight calculation (the central formula)
`OrdersService.createOrder` (orders.service.ts:32-109):
- Per line: `netPercent = wagePercent - discountPercent`; `computedWeightGrams = rashWeight * (netPercent/100 + 1)` — all `Prisma.Decimal` arithmetic (`.sub`, `.div`, `.add`, `.mul`), **no float** (orders.service.ts:39-43).
- `rashWeight` is snapshotted from the variant at order time and never re-read (orders.service.ts:39, schema.prisma:449).
- `totalGoldWeightGrams = Σ computedWeight * qty` (orders.service.ts:57-60); `totalRashWeightGrams = Σ rash * qty` (:61-64); `wageWeightGrams = total - totalRash` (:65).
- `flatRialCharge = Σ item.flatRialCharge * qty` (:66-69).
- **No rounding / quantization is applied in code** — precision is enforced only by the column types `Decimal(14,4)` / `Decimal(20,2)` at write time (schema.prisma:420-424).
- Admin path (`AdminOrdersService.createFromAdmin`, admin-orders.service.ts:287-339) deliberately delegates to `OrdersService.createOrder` rather than recomputing; client-sent totals (`total_price`, `total_weight`, `gold_price_original`) are **ignored and recomputed server-side** (admin-orders.service.ts:283-286). Wage falls back to `variant.defaultWagePercent` when not negotiated (:316).
### 8.3 Fees / commissions / tax / spread
- **Wage (اجرت / making charge)** is the only fee: a percentage on the raw weight, expressed as extra gold weight, not money (`ProductVariant.defaultWagePercent`, `OrderItem.wagePercentSnapshot`).
- **Discount**: `OrderItem.discountPercentSnapshot`, subtracted from wage before applying.
- **Flat Rial charge**: per-unit non-gold cost, e.g. stones (`Order.flatRialCharge`, schema.prisma:423-424).
- **Agent commission**: `AdminUser.wagePercent Decimal(6,3)` (schema.prisma:141) — stored and displayed (sales-agent.service.ts:92) but **never applied to any calculation**.
- **City surcharges**: `City.percent` / `percentAux` (schema.prisma:118-121), readable via `GET admin/misc/cities/percent/:id` and `percent-aux/:id` (admin-misc.controller.ts:40-50) but **never used in pricing**.
- **Bulk wage change**: `AdminProductsService.changePercent` sets `defaultWagePercent` across a brand/category filter via `updateMany`, using `new Prisma.Decimal(String(percent))` (admin-products.service.ts:641-657); `changePercentCount` previews the affected count (:630-639).
- **Tax / VAT**: **no tax logic anywhere**.
- **Spread / buy-vs-sell price**: **none** — a single `GoldPriceQuote.pricePerGramRial` per karat, no bid/ask.
### 8.4 Rounding & precision (Decimal vs float) — exact findings
- **Schema**: zero `Float` fields; every monetary/weight/percent column is `Decimal` with explicit `@db.Decimal(p,s)` (see §6.3), per the stated design rule (schema.prisma:4).
- **Ledger math**: entirely `Prisma.Decimal` — `amount.lte(0)`, `.neg()`, `.mul()`, `.div()`, `.sub()`, `.gt()` (ledger.service.ts:34-35, 95, 131, 178, 189, 300-302).
- **Order math**: entirely `Prisma.Decimal` (orders.service.ts:39-69).
- **Float leaks found (3)**:
1. `ledger.service.ts:189` — CASH conversion divides by `GRAMS_PER_MESGHAL`, a **JS number literal `4.331802`** (`packages/shared-types/src/index.ts:9`). `Decimal.div(number)` coerces the number, so the constant itself carries binary-float representation into an otherwise-Decimal chain. The formula is flagged in its own comment as "worth stakeholder review" (ledger.service.ts:186-187).
2. `admin-dashboard.service.ts:148` and `:347` — sorting aggregates with `Number(b.weight) - Number(a.weight)`, i.e. Decimal→float for ordering only (display, not stored).
3. `prisma/seed.ts:500, 509, 547, 560` — `parseFloat(...).toFixed(4)` to compute demo order weights. Seed-only; not a runtime path.
- **Explicit rounding calls**: none. No `Math.round`, `toFixed` or `Decimal.toDecimalPlaces` in `src/`. All quantization is implicit at the DB column scale.
- **Karat normalization**: `goldGrams = raw * karatPerMille / 750`, with `STANDARD_KARAT_PER_MILLE = 750` (ledger.service.ts:178, `packages/shared-types/src/index.ts:6`).
### 8.5 Wallet / balance
- **Source of truth**: `LedgerEntry` (append-only). `Customer.goldDebtGrams` / `fiatDebtRial` are **denormalized caches** (schema.prisma:198-202, ledger.service.ts:23-27).
- `applyToBalance`: `signedDelta = DEBIT ? amount : -amount`, applied with Prisma `{ increment: signedDelta }` on the matching currency field (ledger.service.ts:88-103).
- `getBalance` reads the cache + credit limit (ledger.service.ts:270-282).
- `reconcileBalance` recomputes from **confirmed** entries only: `goldDebits - goldCredits`, `rialDebits - rialCredits`, and **overwrites** the cache (ledger.service.ts:284-304); `sumConfirmed` uses `aggregate._sum` filtered on `confirmed: true` (:306-312). Documented as periodic/on-demand self-healing, unlike legacy's recompute-on-every-write (:284-288). **Nothing schedules it** — it only runs via `POST /api/accounting/customers/:customerId/reconcile`, which is `@Public()`.
- **Wallet bonus** is a separate concept from debt: `WalletBonus` in gold grams with a mandatory `expiresAt`. `chargeWallet` validates `grams > 0` and applies a **hardcoded one-year expiry** because the column is required (admin-customers.service.ts:734-749, expiry at :743-744). It does **not** post a ledger entry, so a granted bonus never touches `goldDebtGrams` despite `LedgerSourceType.WALLET_BONUS` existing (schema.prisma:106) — that enum value has no producer in `src/`.
- Wallet listings: `AdminDocumentsService.listWallet` (admin-documents.service.ts:369-409), `AdminCustomersService.customerWallet` (admin-customers.service.ts:701-714).
### 8.6 Buy / sell orders
- There is **no buy/sell gold trading flow**. "Orders" are jewelry/product orders that incur gold-weight debt. No `BUY`/`SELL` side, no order matching, no quote-locking.
- Order placement: `OrdersService.createOrder` (orders.service.ts:32-109); admin builder path `AdminOrdersService.createFromAdmin` (admin-orders.service.ts:287-339).
- Order number: `ORD-${Date.now()}-${random6}` (orders.service.ts:148-151) — **not** collision-proof by construction, relies on the `@unique` column.
- Order deletion refuses once ledger entries exist (admin-orders.service.ts:217-221); otherwise cascades events+items+order in one transaction (:223-228).
### 8.7 Deposit / withdraw / transfers
- **No customer-initiated deposit or withdrawal, and no customer-to-customer transfer.** "TRANSFER" (`REPAYMENT_TRANSFER`) means a *bank remittance repayment instrument*, not an internal transfer (ledger.service.ts:193-196, schema.prisma:104).
- The only inbound value flow is a **repayment document** filed by an admin (§8.9).
### 8.8 Order & transaction statuses and transitions
**Order status labels** (admin-orders.service.ts:16-25):
| Enum | Farsi label |
|---|---|
| SUSPENDED | معلق (سقف اعتبار) |
| PLACED | ثبت شده |
| ADMIN_APPROVED | تایید مدیر |
| SOURCING | در حال تامین |
| PREPARING | در حال آماده‌سازی |
| DELIVERED_TO_AGENT | تحویل به نماینده |
| DELIVERED_TO_CUSTOMER | تحویل به مشتری |
| CANCELLED | لغو شده |
**Declared flow** (`STATUS_FLOW`, admin-orders.service.ts:28-35): PLACED → ADMIN_APPROVED → SOURCING → PREPARING → DELIVERED_TO_AGENT → DELIVERED_TO_CUSTOMER. SUSPENDED and CANCELLED are outside the flow.
**Transition mechanics** (`OrdersService.updateStatus`, orders.service.ts:111-139):
- **No state-machine validation** — any status may be set from any status; `STATUS_FLOW` is used only to render the timeline (admin-orders.service.ts:249-258).
- `deliveredAt` is stamped on **DELIVERED_TO_CUSTOMER** (orders.service.ts:120).
- **Debt recognition fires at DELIVERED_TO_AGENT**, not at DELIVERED_TO_CUSTOMER (orders.service.ts:133-135; mirrors legacy `sts>4`).
- Idempotency guard: debt posts only `if (newStatus === DELIVERED_TO_AGENT && !order.deliveredAt)` (orders.service.ts:133). **Note the asymmetry**: `deliveredAt` is set by DELIVERED_TO_CUSTOMER but guards the DELIVERED_TO_AGENT posting — so two successive DELIVERED_TO_AGENT transitions without an intervening DELIVERED_TO_CUSTOMER would both post debt.
- Every transition writes an `OrderStatusEvent` with `fromStatus`/`toStatus`/`note`/actor (orders.service.ts:124-126).
- Status **removal** (`remove: true`): refuses DELIVERED_TO_AGENT because the debt is already posted (admin-orders.service.ts:381-386); otherwise deletes the newest matching event and rolls the order back to the latest remaining event's status, defaulting to PLACED (:388-404).
- Legacy numeric codes 1–6 map onto the enum in `statusFromLegacyCode` (admin-orders.service.ts:346-356).
**Pre-order statuses**: PLACED, ADMIN_APPROVED, WORKSHOP_APPROVED, READY_TO_SHIP, DELIVERED, CANCELLED (schema.prisma:80-87); diffs recorded as JSON in `PreOrderStatusEvent.changes` (schema.prisma:512). Conversion to a real Order is modelled by `PreOrder.convertedOrderId` (schema.prisma:496-497) but **no service method performs the conversion**.
**Ledger entry "status"**: the boolean `confirmed` is the only lifecycle. Order-debt entries are **self-confirming** (`confirmed: true`); repayments start **pending** (`confirmed: false`) — an intentional deviation from legacy, which auto-confirmed (ledger.service.ts:18-19, 113-146, 213, 65-73).
**Comment statuses**: PENDING → APPROVED/REJECTED via `POST admin/comments/sts` with legacy codes 1/2/3 (schema.prisma:655-659, admin-comments.controller.ts:35, admin-comments.service.ts:150).
### 8.9 Repayments (the real "transaction" path)
`LedgerService.postGoldRepayment` (ledger.service.ts:156-218) normalizes four instruments into **gold grams**, then posts a pending GOLD_GRAM **CREDIT**:
| Instrument | Formula | Required extra | sourceType | Cite |
|---|---|---|---|---|
| MELTED_GOLD | `raw * karatPerMille / 750` | `karatPerMille` (400 if missing) | REPAYMENT_MELTED_GOLD | :173-180 |
| CASH | `raw / (pricePerGramRial / 4.331802)` | `pricePerGramRial` (400 if missing) | REPAYMENT_CASH | :182-191 |
| TRANSFER | `raw` (1:1 grams) | — | REPAYMENT_TRANSFER | :193-196 |
| CHEQUE | `raw` (1:1 grams) | — | REPAYMENT_CHEQUE | :197-200 |
`postRialRepayment` posts a RIAL CREDIT with `sourceType: REPAYMENT_CASH`, also pending (ledger.service.ts:225-242) — explicitly noted as **not present in legacy** (:220-224).
`postAdjustment` posts an ADJUSTMENT in either currency/direction, **always `confirmed: true`**, requires `note` and `postedByAdminUserId` (ledger.service.ts:244-268) — **no caller in `src/`**.
`postEntry` rejects non-positive amounts (`amount.lte(0)` → 400, ledger.service.ts:34-36).
`confirmEntry` is idempotent (returns early if already confirmed) and then applies to the balance (ledger.service.ts:74-86).
Admin document path (`AdminDocumentsService`, admin-documents.service.ts):
- Legacy type codes 1..4 → LedgerSourceType (:26-31); instrument name mapping (:34-39); Farsi labels (:48-57).
- `create` picks the raw figure per instrument: CASH→`price`, MELTED_GOLD→`original_value ?? value`, else `value` (:226-231), delegates to `postGoldRepayment`, then patches instrument metadata (:233-249).
- `toggleConfirm` is a **bidirectional** toggle: confirm delegates to `LedgerService.confirmEntry`; un-confirm reverses the cached balance in a transaction (`CREDIT → +amount`, `DEBIT → -amount`) and clears the flag (:309-338).
- `update` **refuses to change the amount of a confirmed entry** (400, :284-288); allows metadata/note always and amount on unconfirmed entries (:290-294).
- `remove` **refuses to delete a confirmed entry** (400, :347-357).
### 8.10 Credit limit / suspension (the "limits" logic)
- Single limit: `Customer.goldCreditLimitGrams` (legacy `gram_level`).
- Check at placement: `isSuspendedOverCreditLimit = (totalGoldWeightGrams + customer.goldDebtGrams) > customer.goldCreditLimitGrams` (orders.service.ts:71-74).
- Over limit → the order is **created with status SUSPENDED**, not rejected; the flag is persisted and a status event notes "Credit limit exceeded" (orders.service.ts:75, 88, 101).
- **The check uses the cached `goldDebtGrams`**, which excludes pending repayments (ledger.service.ts:306-312) — so an unconfirmed repayment does not relax the limit.
- **`canPlaceOrders` is never enforced** in `OrdersService.createOrder` — it is toggled (admin-customers.service.ts:773-780) and exposed, but no order path reads it.
- Limit edits: `updateCredit` (requires `gram_level`, admin-customers.service.ts:852-864) and `update` (:612-614), both via `new Prisma.Decimal(String(...))`.
- **No per-order, daily, or per-customer transaction-count limits exist.**
### 8.11 KYC / verification
- `VerificationStep` enum: UNVERIFIED → STEP_ONE_SUBMITTED → STEP_TWO_SUBMITTED → VERIFIED (schema.prisma:54-59).
- Document URLs on Customer: `nationalIdImageUrl`, `businessLicenseUrl`, `shopImageUrl` (schema.prisma:205-207); persisted on create from `national_id_image`/`business_license`/`shop_image` (admin-customers.service.ts:591-593).
- `AdminCustomersService.verify` (admin-customers.service.ts:790-816) takes legacy `{ customer_id, sts_verify_step_one|sts_verify_step_two }` where **2 = accept, anything else = reject**:
- step-one accept → `STEP_TWO_SUBMITTED`; step-one reject → `UNVERIFIED` (:801-805)
- step-two accept → `VERIFIED`; step-two reject → `STEP_ONE_SUBMITTED` (:806-810)
- neither key → 400 (:811-813)
- **Verification is never enforced anywhere** — no order, ledger or limit path reads `verificationStep`. The `non_verified` list scope only filters the grid (admin-customers.service.ts:170-193).
- `nationalCode` is `@unique` but falls back to the **mobile number** when not supplied (admin-customers.service.ts:530, 567) — so retail customers systematically carry a national code equal to their phone.
### 8.12 OTP / SMS
- **No OTP implementation of any kind**: no OTP model, no code generation, no verification endpoint, no expiry. (Grep for `otp` over `src/` returns nothing.)
- **SMS is persistence-only** — no provider, no HTTP call. `AdminMiscService.sendSms` validates a non-empty body, resolves the audience and recipient list, and writes an `SmsMessage` row with `sentCount = recipients.length` (admin-misc.service.ts:176-195). Nothing is actually transmitted.
- Audience codes (admin-misc.service.ts:205-221): `-1`→SINGLE_NUMBER, `0`→ALL_CUSTOMERS, `1`→SALES_AGENTS, `2`→WHOLESALE_CUSTOMERS, `9`→MULTIPLE_NUMBERS; plus name aliases single/multiple/all/omde/tak/reagent. `RETAIL_CUSTOMERS` is reachable only by name — the legacy markup had no numeric option for it (noted :202-203).
- The customer reactivation SMS legacy sent is deliberately **not** fired (admin-customers.service.ts:754-757).
### 8.13 Notifications
- `Notification` + `NotificationRecipient` (per-customer read state), schema.prisma:831-858.
- `createNotification` (broadcast by audience) / `notifyCustomer` (single) / `markNotificationRead` / `findNotification` / `listNotifications` (admin-misc.service.ts:263, 291, 388, 317, 347).
- **Delivery is in-database only** — no push, email or SMS dispatch.
### 8.14 Payment gateway + callbacks
- **None.** No gateway module, no redirect/verify endpoints, no callback route, no `zarinpal`/`sadad`/`IPG` reference, no signature verification, and no outbound HTTP client in the dependency list. Money enters the system exclusively as an admin-filed repayment document (§8.9).
### 8.15 File uploads
- `AdminUploadsService.store` (admin-uploads.service.ts:37-69):
- 400 if no file (:42); **max 8 MB** `MAX_BYTES = 8*1024*1024` (:33, 43-45)
- MIME allowlist: `image/(jpeg|png|gif|webp|svg+xml)` or `application/pdf` (:30, 46-48) — note **SVG is accepted**, and it is later served inline (public-uploads.controller.ts:23-26), an XSS vector
- Stored name: `md5(Date.now() + originalname + randomUUID()) + ext` (:50-54)
- Written to `getUploadDir()`, row created with `url = '/uploads/<stored>'` (:56-68)
- Path resolution + traversal defence: `resolveStoredUpload` takes `basename`, rejects `.`/`..`, checks the resolved path stays under the upload dir, and also probes a nested `uploads/uploads/` leftover (upload-path.ts:34-48).
- Serving: static mount at `/uploads/` (main.ts:28-36) **and** a streaming controller at `/api/uploads/:filename` with a 1-day cache header (public-uploads.controller.ts:17-27), because the user-app's axios base is `/api` (:9-13).
- Deliberately **not** ported: webp re-encoding, watermarking, thumbnailing (dead in legacy too) (admin-uploads.service.ts:20-22).
### 8.16 Settings
- One key/value table: `Setting { key @id, value Json, updatedAt }` (schema.prisma:690-694), replacing legacy `config_static`.
- Generic CRUD: `getConst` / `setConst` / `listConsts` / `removeConst` (admin-misc.service.ts:464-491), exposed at `GET|PUT|DELETE admin/misc/const/:key` under `config.statics`.
- **Known keys**: `wallet.config`, `wallet.birthday` (admin-documents.controller.ts:51,58,64,71), `promotion.auto` (admin-promotions.service.ts:257,262,275-280), `mobile_main_slider` (admin-misc.controller.ts:188,193,199).
- The slider key is **privilege-separated**: dedicated routes require `custom.config.statics.slider`, and `assertNotSliderKey` blocks reaching `mobile_main_slider` through the generic `config.statics` routes (admin-misc.controller.ts:184-200, 224-233).
- `setAutoPromotion` validates `0 ≤ discount_percent ≤ 100` (admin-promotions.service.ts:261-266).
- Settings are **read with fallbacks, never seeded** (§7.2).
### 8.17 XTable query engine (`TableService`) — shared by every admin grid
- Operators → Prisma: `=`,`!=`,`>`,`>=`,`<`,`<=`,`like`(→`contains`, `%` stripped, `mode:'insensitive'`),`in`,`between`(value+value2),`null`,`notnull` (table.service.ts:56-91).
- Field allowlist enforced per call; unknown field → 400 `Field not filterable` (table.service.ts:49-53) — also applied to sort fields (:121).
- Dotted paths nested into relation filters (`customer.fullName` → `{customer:{fullName:…}}`) (table.service.ts:42-47).
- Defaults: `page=1`, `limit=10` (table.service.ts:95-96); `limit` capped at 500 by the DTO (`@Max(500)`).
- Sort: `'+field'` asc / `'-field'` desc, default `{createdAt:'desc'}` (table.service.ts:116-124).
- Response envelope `{ rows, query:{search,filters,total,limit,page} }` (table.service.ts:137-148).
- **Exports are unimplemented and rejected loudly**: any `op` → 400 (table.service.ts:150-157), called by every list method as `assertNotExport`.
### 8.18 Branch scoping
`AdminOrdersService.list` narrows to `admin.branchId` whenever the caller has one (admin-orders.controller.ts:39, admin-orders.service.ts:75-81), faithfully reproducing legacy. The seed keeps head-office admins branch-less precisely so they are not scoped (seed.ts:140-143, admin-orders.controller.ts:33-37). Same pattern for `listSingle` (admin-customers.controller.ts:65).
---
## 9. Auth
| Concern | Implementation | Cite |
|---|---|---|
| Strategy | `passport-jwt`, named `'jwt'`, token from `Authorization: Bearer` | jwt.strategy.ts:30-37 |
| **JWT secret** | `process.env.JWT_SECRET ?? 'dev-only-insecure-secret'` — **same insecure fallback in two places** | auth.module.ts:16, jwt.strategy.ts:35 |
| **Expiry** | `process.env.JWT_EXPIRES_IN ?? '12h'`, cast to `` `${number}h` `` | auth.module.ts:20 |
| `ignoreExpiration` | `false` (expiry enforced) | jwt.strategy.ts:34 |
| **Refresh tokens** | **NONE** — no refresh endpoint, no refresh token, no rotation, no revocation/blacklist. A token is valid until it expires. | (absent) |
| Token payload | `{ sub: user.id, mobile }` only — **no roles/permissions baked in** | auth.service.ts:43, jwt.strategy.ts:7-10 |
| Per-request principal | `validate()` re-reads the AdminUser + userType.permissions on **every request**, so permission changes take effect immediately | jwt.strategy.ts:39-61, comment :49-50 |
| Inactive users | `!user \|\| !user.isActive` → 401 | jwt.strategy.ts:45-47 |
| Password hashing | bcryptjs, `bcrypt.compare` on login; cost 10 on customer create and in the seed | auth.service.ts:38, admin-customers.service.ts:572, seed.ts:17 |
| Login response | `{ id, name, fullName, mobile, role, ut, branch_id, api_token, roles:[{rid}] }` — legacy shape | auth.service.ts:45-55 |
| Enumeration defence | bad phone and bad password both return **401** (legacy returned 404/422) | auth.service.ts:24-26, 34-41 |
| `GET admin/auth/me` | returns **raw (unexpanded)** rids as `permission[]`; the frontend runs checkParent() | auth.service.ts:58-88, esp. :86 |
**Guards** — both registered globally via `APP_GUARD` in AuthModule (auth.module.ts:29-30):
| Guard | Behaviour | Cite |
|---|---|---|
| `JwtAuthGuard extends AuthGuard('jwt')` | Reads `IS_PUBLIC_KEY` via `reflector.getAllAndOverride` on handler **and class**; `@Public()` → allow; otherwise `super.canActivate`. Secure-by-default (inverse of legacy's allowlist). | guards.ts:35-49, comment :27-34 |
| `PermissionsGuard` | Reads `REQUIRED_RID_KEY`; **no rid → allow**. Otherwise requires `req.user` (else 403) and `user.permissions.includes(requiredRid)` (else 403 `Missing permission: <rid>`). | guards.ts:52-71 |
**Decorators**:
| Decorator | Key | Cite |
|---|---|---|
| `@Public()` | `SetMetadata('isPublic', true)` | guards.ts:14-15 |
| `@RequirePermission(rid)` | `SetMetadata('requiredRid', rid)` | guards.ts:18-19 |
| `@CurrentAdmin()` | `createParamDecorator` → `req.user` as `AuthenticatedAdmin` | guards.ts:22-25 |
**Permission model**:
- Permissions attach to the **UserType** (`ut`), never to an individual admin — every admin of a type shares one set (schema.prisma:600-618, Permission composite PK `[rid, userTypeId]` :641).
- `expandWithParents(rids)` derives ancestors at request time: granting `persons.list.client.omde` implicitly grants `persons.list.client`, `persons.list`, `persons` (acl.constants.ts:194-203, applied jwt.strategy.ts:60). Port of legacy `checkParent()`.
- `getParents` walks up only — **a rid never implies its children**; the resulting legacy quirk (holding `wallet` does not grant `wallet.list`) is **deliberately preserved** as a documented legacy bug (acl.constants.ts:171-192).
- `setRoles` **replaces** a user type's whole rid set inside one transaction (deleteMany + createMany skipDuplicates) — and performs **no validation against `ALL_RIDS`**, so an arbitrary string can be stored as a rid (admin-users.service.ts:317-332).
- `revokeRole` removes one rid, 404 if absent (admin-users.service.ts:335-346).
- `AdminRole` (the coarse enum) is used for business rules, not for route gating — the only enforcement is `SalesAgentService.assertIsSalesExpert` (`role !== 'SALES_EXPERT'` → 403, sales-agent.service.ts:36-40) and `queryReagents` filtering `role: 'SALES_EXPERT'` (admin-customers.service.ts:719).
### 9.1 All role names
**`AdminRole` (5)** — schema.prisma:21-27, labels admin-users.service.ts:353-361:
`SUPER_ADMIN` (مدیر ارشد), `SYSTEM_APPROVER` (تایید مدیر سیستم), `SALES_EXPERT` (کارشناس فروش), `WORKSHOP_SUPERVISOR` (سرپرست کارگاه), `ACCOUNTANT` (حسابدار).
**`UserType` rows seeded (5)** — seed.ts:66-98: 85 مدیر ارشد, 86 کارشناس فروش, 87 حسابدار, 88 سرپرست کارگاه, 89 کاربر شعبه.
### 9.2 All permission names (`ALL_RIDS`, 144 leaf rids)
Source: `src/modules/auth/acl.constants.ts:17-161`. Derived as the union of 58 route-gated (`meta.role`) + 100 in-view (`$hasPermission`) legacy rids (acl.constants.ts:4-13).
| Group | Rids |
|---|---|
| branch | `branch.create`, `branch.list` |
| buy | `buy.order.list` |
| cities | `cities.create`, `cities.list`, `cities.list.remove`, `cities.list.view`, `cities.list.view.edit` |
| collection | `collection.create`, `collection.list`, `collection.list.edit`, `collection.list.remove` |
| comment | `comment`, `comment.remove`, `comment.state`, `comment.view`, `comment.view.edit` |
| config | `config.report`, `config.statics`, `config.statics.brand`, `config.statics.brand.create`, `config.statics.brand.edit`, `config.statics.brand.remove`, `config.statics.tags`, `config.statics.tags.create`, `config.statics.tags.edit`, `config.statics.tags.remove`, `config.wallet` |
| custom | `custom.config.statics.slider`, `custom.config.statics.tags.banner.ids` |
| dashboard | `dashboard.view` |
| document | `document.create`, `document.list`, `document.list.confirm`, `document.list.deny`, `document.list.edit`, `document.list.get`, `document.list.remove` |
| invoice | `invoice.create`, `invoice.list` |
| newsletters | `newsletters` |
| notify | `notify.create`, `notify.me` |
| offers | `offers.autopromotion`, `offers.create`, `offers.create.tak`, `offers.list`, `offers.list.edit`, `offers.list.remove`, `offers.list.tak.edit`, `offers.list.tak.remove`, `offersIgnore` |
| order | `order.create`, `order.list`, `order.list.pdf`, `order.list.sts`, `order.list.sts.remove`, `order.list.sts.submit`, `order.list.view`, `order.list.view.edit`, `order.makeOrder`, `order.makeOrder.get`, `order.makeOrder.remove`, `order.makeOrder.state`, `order.makeOrder.state.remove`, `order.makeOrder.state.submit`, `order.makeOrder.view`, `order.makeOrder.view.edit`, `order.omde`, `order.omde.get`, `order.omde.remove`, `order.omde.state`, `order.omde.state.remove`, `order.omde.state.submit`, `order.omde.view`, `order.omde.view.edit`, `order.omde.view.remove`, `order.tamin`, `order.tamin.state`, `order.tamin.state.remove`, `order.tamin.state.submit`, `order.tamin.view`, `order.tamin.view.edit` |
| persons | `persons.create`, `persons.create.client`, `persons.create.customer.tak`, `persons.create.seller`, `persons.list.client.omde`, `persons.list.client.omde.able`, `persons.list.client.omde.able.order`, `persons.list.client.omde.edit`, `persons.list.client.omde.unable`, `persons.list.client.omde.unable.order`, `persons.list.client.omde.view`, `persons.list.client.omde.view.documents`, `persons.list.client.omde.view.orders`, `persons.list.client.single`, `persons.list.client.single.edit`, `persons.list.client.single.view`, `persons.list.sellers`, `persons.list.sellers.able`, `persons.list.sellers.able.order`, `persons.list.sellers.edit`, `persons.list.sellers.unable`, `persons.list.sellers.unable.order`, `persons.list.sellers.view`, `persons.list.sellers.view.documents`, `persons.list.sellers.view.orders` |
| product | `product.create`, `product.list`, `product.list.edit`, `product.list.group.edit`, `product.list.remove`, `product.list.variants`, `product.list.variants.add`, `product.list.variants.edit`, `product.list.variants.remove`, `product.list.view`, `product.percent` |
| report | `report.order.item`, `report.product` |
| sms | `sms.create`, `sms.create.all`, `sms.create.multiple`, `sms.create.omde`, `sms.create.reagent`, `sms.create.single`, `sms.list` |
| statics | `statics` |
| user | `user.create`, `user.list`, `user.list.create`, `user.list.edit`, `user.list.remove`, `user.permission`, `user.permission.create`, `user.permission.edit` |
| wallet | `wallet`, `wallet.charge`, `wallet.list`, `wallet.percent` |
| workshop | `workshop.create`, `workshop.list` |
**Rids declared but never enforced by any route** (declared-only; the panel may use them client-side): `comment.remove`, `comment.state`, `comment.view`, `comment.view.edit`, `config.statics.tags*` (4), `custom.config.statics.tags.banner.ids`, `document.list.confirm`, `document.list.deny`, `document.list.get`, `document.list.remove`, `cities.list.view`, `collection.list.remove`, `offers.create.tak`, `offers.list.remove`, `offers.list.tak.edit`, `offers.list.tak.remove`, `order.list.pdf`, `order.list.sts*`, `order.list.view*`, `order.makeOrder.get/remove/state*/view*`, `order.omde.get/remove/state*/view*`, `order.tamin.state*/view*`, `persons.create.client/customer.tak/seller`, `persons.list.*.able/unable*`, `persons.list.client.single.edit`, `persons.list.sellers.edit/able/unable*/view.documents/view.orders`, `product.list.group.edit/remove/variants*/view`, `sms.create.all/multiple/omde/reagent/single`, `user.list.create`, `wallet.charge/list/percent`, `buy.order.list`, `newsletters` is enforced, `statics` is enforced.
---
## 10. Jobs / cron / schedulers / websockets
**None of any kind.** Verified by grep over `src/`: no `@nestjs/schedule`, `ScheduleModule`, `@Cron`, `@Interval`, `setInterval`, `node-cron`, `@WebSocketGateway`, `SubscribeMessage`, `OnGatewayInit`, or `socket.io`. Confirmed absent from `package.json` dependencies too.
The only non-request-triggered work is the **boot-time** sequence in `prepareDatabase()` (bootstrap.ts:104-134), executed once before `NestFactory.create`:
1. Returns immediately if `RUN_MIGRATIONS === 'false'` (bootstrap.ts:105).
2. `runMigrations(prisma)` applies pending `.sql` files and logs which were applied (bootstrap.ts:111-117).
3. Returns if `RUN_SEED === 'false'` (bootstrap.ts:119).
4. Resolves the seed script from `SEED_SCRIPT` / `cwd/seed.js` / `../../seed.js`; warns and skips if none found (bootstrap.ts:12-19, 121-125).
5. `runOnce(prisma, 'seed:initial', ...)` spawns the seed as a child process, gated on a marker row so an interrupted seed is retried next boot (bootstrap.ts:127-131, comment :100-102).
6. Always `$disconnect()` in `finally` (bootstrap.ts:132-134).
Consequences worth noting: `reconcileBalance` (ledger.service.ts:289) is described as "run periodically" but **has no scheduler**; `WalletBonus.expiresAt` is never swept; `GoldPriceQuote` is never refreshed.
---
## 11. Env / config keys
**No `@nestjs/config` / `ConfigService` anywhere** — every value is read directly from `process.env` at module-evaluation or call time, which means `JwtModule.register` captures `JWT_SECRET` once at import (auth.module.ts:15-22).
| Key | Used at | Default / fallback | Purpose |
|---|---|---|---|
| `DATABASE_URL` | prisma/schema.prisma:14 (`env("DATABASE_URL")`) | none (required) | Postgres connection |
| `PORT` | main.ts:37 | `3000` | HTTP listen port |
| `UPLOAD_DIR` | main.ts:28, upload-path.ts:13 | `<cwd>/storage/uploads` | upload read/write root |
| `JWT_SECRET` | auth.module.ts:16, jwt.strategy.ts:35 | **`'dev-only-insecure-secret'`** | JWT signing + verification |
| `JWT_EXPIRES_IN` | auth.module.ts:20 | `'12h'` | token lifetime |
| `RUN_MIGRATIONS` | bootstrap.ts:29 | unset → migrations run; `'false'` skips | boot-time migration switch |
| `RUN_SEED` | bootstrap.ts:43 | unset → seed runs once; `'false'` skips | boot-time seed switch |
| `SEED_SCRIPT` | bootstrap.ts:13 | falls back to `cwd/seed.js`, `../../seed.js` | seed script location |
| `MIGRATIONS_DIR` | migrate.ts:22 | falls back to `cwd/migrations`, `../../migrations`, `../../prisma/migrations`, `cwd/prisma/migrations` | migrations location |
`.env.example` declares only **2 of the 9**: `DATABASE_URL` and `PORT` (.env.example:1-7). `PORT` must be kept in sync manually with the repo-root `API_PORT` because Nest/Prisma only load this app's own `.env` (.env.example:3-6). `JWT_SECRET` in particular is undocumented there, so a deployment that forgets it silently runs on the hardcoded dev secret.
---
## 12. Cross-cutting notes (evidence-backed)
| # | Observation | Cite |
|---|---|---|
| 1 | 15 routes are fully unauthenticated, including ledger writes (`POST accounting/repayments/*`, `POST accounting/entries/:id/confirm`) and order creation/status changes (`POST orders`, `PATCH orders/:id/status`) | accounting.controller.ts:7, orders.controller.ts:7 |
| 2 | `orders/dto/update-order-status.dto.ts` accepts a client-supplied `actorAdminUserId` on a `@Public()` route, so audit actor is attacker-controlled there | update-order-status.dto.ts:12-14, orders.controller.ts:22-24 |
| 3 | `JWT_SECRET` has an insecure hardcoded fallback duplicated in two files and is absent from `.env.example` | auth.module.ts:16, jwt.strategy.ts:35, .env.example |
| 4 | `enableCors()` with no options → `Access-Control-Allow-Origin: *` for every route | main.ts:25 |
| 5 | `ValidationPipe` lacks `forbidNonWhitelisted`; most admin bodies are `Record<string, unknown>`, so whitelisting has nothing to strip | main.ts:23, e.g. admin-products.controller.ts:103 |
| 6 | `setRoles` stores arbitrary strings as rids — no check against `ALL_RIDS` | admin-users.service.ts:317-332 |
| 7 | Debt idempotency guard for DELIVERED_TO_AGENT tests `deliveredAt`, which is only set by DELIVERED_TO_CUSTOMER | orders.service.ts:120, 133 |
| 8 | No status-transition validation — any `OrderStatus` reachable from any other | orders.service.ts:111-139 |
| 9 | `canPlaceOrders` and `verificationStep` are toggled/stored but never enforced on any order path | admin-customers.service.ts:773-780, 790-816 vs orders.service.ts:32-109 |
| 10 | `reconcileBalance`, `postAdjustment`, `PricingService.recordQuote`, and `LedgerSourceType.WALLET_BONUS`/`ORDER_PRE_ORDER` have no scheduled or in-app producer | ledger.service.ts:244-304, pricing.service.ts:9-13, schema.prisma:100,106 |
| 11 | SVG accepted on upload and streamed back `disposition: 'inline'` | admin-uploads.service.ts:30, public-uploads.controller.ts:23-26 |
| 12 | `nationalCode` (unique) silently defaults to the mobile number | admin-customers.service.ts:530, 567 |
| 13 | Two distinct DTO classes share the name `UpdateOrderStatusDto` with different shapes | orders/dto/update-order-status.dto.ts:4, admin-orders/update-status.dto.ts:11 |
| 14 | Grid exports (`op: excel/json/print`) are declared in the DTO but always 400 | table-query.dto.ts:97-99, table.service.ts:150-157 |
| 15 | The `Setting` table is never seeded; all config keys materialize on first write | seed.ts (no `setting.` call), admin-documents.service.ts:416-428 |
| 16 | Seed ships a dev account whose password equals its phone number (`09128081392`) with SUPER_ADMIN + all 144 rids | seed.ts:23-24, 118-129 |