# 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` (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` | | 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` | ### 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` | | 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` | | PUT | `/api/admin/customers-omde/:id` | `update` (:103) | `persons.list.client.omde.edit` (:102) | `Record` | | 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` | | 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` | ### 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` | | GET | `/api/admin/categories/:id` | `findOne` (:77) | `collection.list` (:76) | — | | PUT | `/api/admin/categories/:id` | `update` (:83) | `collection.list.edit` (:82) | `Record` | | 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` | | 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` | | POST | `/api/admin/documents` | `create` (:77) | `@HttpCode(200)`, `document.create` (:75-76), `@CurrentAdmin()` | `Record` | | 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` | | 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` | | GET | `/api/admin/promotions/:id` | `findOne` (:78) | `offers.list` (:77) | — | | PUT | `/api/admin/promotions/:id` | `update` (:84) | `offers.list.edit` (:83) | `Record` | | 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` | | GET | `/api/admin/users/:id` | `findOne` (:85) | `user.list` (:84) | — | | PUT | `/api/admin/users/:id` | `update` (:91) | `user.list.edit` (:90) | `Record` | | 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` | | 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` | ### 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` | | 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` | | 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`, 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` 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` | `@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/'` (: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: `). | 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 | `/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`, 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 |