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

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

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

[ref: henza_server, henza_admin, henza_pwa]

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

95 KiB
Raw Permalink Blame History

Inventory — apps/api (target backend)

Evidence-based inventory. All path:line citations are relative to apps/api/. Read-only audit; no source file was modified.


1. Stack & versions

Source: package.json.

Item Value Cite
Package name / version api @ 0.0.1, private package.json:2-4
NestJS core @nestjs/core ^10.4.0 package.json:27
NestJS common @nestjs/common ^10.4.0 package.json:26
NestJS platform @nestjs/platform-express ^10.4.0 package.json:30
NestJS JWT @nestjs/jwt ^12.0.1 package.json:28
NestJS Passport @nestjs/passport ^10.0.3 package.json:29
Prisma client @prisma/client ^5.19.0 package.json:31
Prisma CLI (dev) prisma ^5.19.0 package.json:47
bcryptjs ^3.0.3 package.json:32
class-transformer ^0.5.1 package.json:33
class-validator ^0.14.1 package.json:34
passport / passport-jwt ^0.7.0 / ^4.0.1 package.json:35-36
rxjs ^7.8.1 package.json:38
Workspace dep @gold-bank/shared-types (workspace:*) package.json:25
TypeScript ^5.5.0 package.json:50
esbuild (release bundler) ^0.25.0 package.json:46
ts-node ^10.9.2 package.json:49

NOT present (relevant negatives): no @nestjs/config, no @nestjs/schedule, no @nestjs/websockets/socket.io, no @nestjs/swagger, no @nestjs/throttler, no HTTP client (axios), no SMS/payment SDK, no multer types (multer ships inside platform-express; a local interface is declared instead — admin-uploads.controller.ts:21-26).

Scripts: dev = nest start --watch; build:release = prisma generate → clean-dist → nest build → bundle-release (package.json:7-9). Prisma seed command: ts-node --transpile-only prisma/seed.ts (package.json:19-21).


2. Bootstrap

2.1 src/main.ts

Concern Value Cite
Pre-boot hook await prepareDatabase() — runs SQL migrations + one-time seed BEFORE Nest is created main.ts:19
App type NestExpressApplication main.ts:21
Global prefix api main.ts:22
Global pipe ValidationPipe({ whitelist: true, transform: true }) — no forbidNonWhitelisted, no transformOptions main.ts:23
Global filter PrismaExceptionFilter main.ts:24
Global interceptors NONE registered (absent in main.ts)
CORS app.enableCors() — defaults, i.e. origin: *, no credentials, no allowlist main.ts:25
Static assets useStaticAssets(UPLOAD_DIR ?? cwd/storage/uploads, { prefix: '/uploads/', index:false, dotfiles:'deny' }) — mounted OUTSIDE the api prefix main.ts:28-36
Custom MIME .avif → image/avif injected via setHeaders (serve-static's bundled mime predates avif) main.ts:11-16, 32-35
Port Number(process.env.PORT ?? 3000) main.ts:37
Bind host 0.0.0.0 (explicit, for nginx/container reachability) main.ts:41
EADDRINUSE Logs guidance and process.exit(1); explicitly does NOT fall back to another port main.ts:42-52

Global guards are NOT registered in main.ts — they are registered via APP_GUARD in AuthModule (see §9).

2.2 src/app.module.ts

@Module({ imports: [...] }), no controllers/providers of its own (app.module.ts:28-57). 24 imported modules, in declared order:

# Module Cite
1 PrismaModule app.module.ts:30
2 AuthModule app.module.ts:31
3 AdminTableModule app.module.ts:32
4 AdminOrdersModule app.module.ts:33
5 AdminCitiesModule app.module.ts:34
6 AdminPreOrdersModule app.module.ts:35
7 AdminWorkshopsModule app.module.ts:36
8 AdminCustomersModule app.module.ts:37
9 AdminCommentsModule app.module.ts:38
10 AdminProductsModule app.module.ts:39
11 AdminCategoriesModule app.module.ts:40
12 AdminDocumentsModule app.module.ts:41
13 AdminPromotionsModule app.module.ts:42
14 AdminUsersModule app.module.ts:43
15 AdminBranchesModule app.module.ts:44
16 AdminInvoicesModule app.module.ts:45
17 AdminMiscModule app.module.ts:46
18 AdminUploadsModule app.module.ts:47
19 AdminDashboardModule app.module.ts:48
20 CatalogModule app.module.ts:49
21 CustomersModule app.module.ts:50
22 SalesAgentModule app.module.ts:51
23 OrdersModule app.module.ts:52
24 AccountingModule app.module.ts:53
25 PricingModule app.module.ts:54

(25 entries; PrismaModule is infrastructure, giving 24 feature/infra modules under src/modules/ + prisma.)


3. Modules (every module under src/)

Module file Controllers Services / Providers Imports Exports
src/prisma/prisma.module.ts — PrismaService (prisma.module.ts:6) — PrismaService (:7)
src/modules/auth/auth.module.ts AuthController (:24) AuthService, JwtStrategy, APP_GUARD→JwtAuthGuard, APP_GUARD→PermissionsGuard (:25-31) PrismaModule, PassportModule, JwtModule.register (:12-23) AuthService (:32)
src/modules/admin-table/admin-table.module.ts — TableService (:10) — TableService (:11)
src/modules/admin-orders/admin-orders.module.ts AdminOrdersController (:9) AdminOrdersService (:10) PrismaModule, OrdersModule (:8) —
src/modules/admin-cities/admin-cities.module.ts AdminCitiesController (:7) — (uses PrismaService directly in controller, admin-cities.controller.ts:13) PrismaModule (:6) —
src/modules/admin-preorders/admin-preorders.module.ts AdminPreOrdersController (:8) AdminPreOrdersService (:9) PrismaModule (:7) —
src/modules/admin-workshops/admin-workshops.module.ts AdminWorkshopsController (:8) — (reuses AdminBranchesService) PrismaModule, AdminBranchesModule (:7) —
src/modules/admin-customers/admin-customers.module.ts AdminCustomersController (:8) AdminCustomersService (:9) PrismaModule (:7) AdminCustomersService (:10)
src/modules/admin-comments/admin-comments.module.ts AdminCommentsController (:8) AdminCommentsService (:9) PrismaModule (:7) —
src/modules/admin-products/admin-products.module.ts AdminProductsController (:8) AdminProductsService (:9) PrismaModule (:7) —
src/modules/admin-categories/admin-categories.module.ts AdminCategoriesController (:8) AdminCategoriesService (:9) PrismaModule (:7) —
src/modules/admin-documents/admin-documents.module.ts AdminDocumentsController (:9) AdminDocumentsService (:10) PrismaModule, AccountingModule (:8) —
src/modules/admin-promotions/admin-promotions.module.ts AdminPromotionsController (:8) AdminPromotionsService (:9) PrismaModule (:7) —
src/modules/admin-users/admin-users.module.ts AdminUsersController (:8) AdminUsersService (:9) PrismaModule (:7) —
src/modules/admin-branches/admin-branches.module.ts AdminBranchesController (:8) AdminBranchesService (:9) PrismaModule (:7) AdminBranchesService (:12)
src/modules/admin-invoices/admin-invoices.module.ts AdminInvoicesController (:8) AdminInvoicesService (:9) PrismaModule (:7) —
src/modules/admin-misc/admin-misc.module.ts AdminMiscController, PublicSliderController (:9) AdminMiscService (:10) PrismaModule (:8) —
src/modules/admin-uploads/admin-uploads.module.ts AdminUploadsController, PublicUploadsController (:9) AdminUploadsService (:10) PrismaModule (:8) AdminUploadsService (:11)
src/modules/admin-dashboard/admin-dashboard.module.ts AdminDashboardController (:8) AdminDashboardService (:9) PrismaModule (:7) —
src/modules/catalog/catalog.module.ts CatalogController (:6) CatalogService (:7) — CatalogService (:8)
src/modules/customers/customers.module.ts CustomersController (:6) CustomersService (:7) — CustomersService (:8)
src/modules/sales-agent/sales-agent.module.ts SalesAgentController (:6) SalesAgentService (:7) — SalesAgentService (:8)
src/modules/orders/orders.module.ts OrdersController (:9) OrdersService (:10) CatalogModule, AccountingModule (:8) OrdersService (:11)
src/modules/accounting/accounting.module.ts AccountingController (:6) LedgerService (:7) — LedgerService (:8)
src/modules/pricing/pricing.module.ts PricingController (:6) PricingService (:7) — PricingService (:8)

Controllers: 25. Services: 22 (+ TableService, PrismaService).


4. Endpoints (exhaustive)

Global prefix api (main.ts:22) is included in every path below. @Public() = JwtAuthGuard bypassed (guards.ts:15). No @RequirePermission = authenticated but no rid check.

4.1 AuthController — @Controller('admin/auth') (auth.controller.ts:15)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/auth/login login (auth.controller.ts:22) @Public(), @HttpCode(200) (:19,21) LoginDto (:22)
GET /api/admin/auth/me me (auth.controller.ts:28) auth required; @CurrentAdmin() (:28) —

4.2 AdminOrdersController — @Controller('admin/orders') (admin-orders.controller.ts:16)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/orders/list list (:24) @HttpCode(200), @RequirePermission('order.list') (:22-23), @CurrentAdmin() TableQueryDto (:24)
POST /api/admin/orders/wholesale/list listWholesale (:46) @HttpCode(200), @RequirePermission('order.omde') (:44-45) TableQueryDto
POST /api/admin/orders/tamin/list listTamin (:54) @HttpCode(200), @RequirePermission('order.tamin') (:52-53) TableQueryDto
POST /api/admin/orders/delete remove (:62) @HttpCode(200), @RequirePermission('order.omde') (:60-61) inline { id: string }
POST /api/admin/orders/create createFromAdmin (:70) @HttpCode(200), @RequirePermission('order.create') (:68-69), @CurrentAdmin() inline { customer_id, order_items[], sts?, note? } (:72-77)
GET /api/admin/orders/type-options typeOptions (:86) @RequirePermission('order.create') (:85) —
GET /api/admin/orders/status-options statusOptions (:92) @RequirePermission('order.list') (:91) —
POST /api/admin/orders/status setStatus (:100) @HttpCode(200), @RequirePermission('order.list') (:98-99), @CurrentAdmin() UpdateOrderStatusDto (admin-orders/update-status.dto.ts)
GET /api/admin/orders/:id findOne (:106) @RequirePermission('order.list') (:105) —

4.3 AdminCitiesController — @Controller('admin/cities') (admin-cities.controller.ts:11)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/cities/list list (:17) @HttpCode(200); no rid, but NOT @Public() → JWT still required (:8-10 comment claims ungated; only the rid check is skipped) —

4.4 AdminPreOrdersController — @Controller('admin/pre-orders') (admin-preorders.controller.ts:13)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/pre-orders/list list (:20) @HttpCode(200), @RequirePermission('order.makeOrder') (:18-19) TableQueryDto
GET /api/admin/pre-orders/status-options statusOptions (:26) @RequirePermission('order.makeOrder') (:25) —
GET /api/admin/pre-orders/:id findOne (:32) @RequirePermission('order.makeOrder') (:31) —
PUT /api/admin/pre-orders/:id update (:38) @RequirePermission('order.makeOrder') (:37) Record<string, unknown> (untyped)
DELETE /api/admin/pre-orders/:id remove (:44) @RequirePermission('order.makeOrder') (:43) —

4.5 AdminWorkshopsController — @Controller('admin/workshops') (admin-workshops.controller.ts:18)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/workshops/list list (:24) @HttpCode(200); no rid (:22-23) TableQueryDto
POST /api/admin/workshops create (:31) @HttpCode(200), @RequirePermission('workshop.create') (:29-30) Record<string, unknown>
GET /api/admin/workshops/active/:id toggleActive (:37) @RequirePermission('workshop.list') (:36) —
GET /api/admin/workshops/:id findOne (:43) @RequirePermission('workshop.list') (:42) —
PUT /api/admin/workshops/:id update (:49) @RequirePermission('workshop.list') (:48) Record<string, unknown>

4.6 AdminCustomersController — @Controller('admin/customers-omde') (admin-customers.controller.ts:17)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/customers-omde/list listAll (:28) @HttpCode(200), @RequirePermission('persons.list.client.omde') (:26-27) TableQueryDto
POST /api/admin/customers-omde/banker/list listBanker (:35) @HttpCode(200), persons.list.client.omde (:33-34) TableQueryDto
POST /api/admin/customers-omde/shopkeeper/list listShopkeeper (:42) @HttpCode(200), persons.list.client.omde (:40-41) TableQueryDto
POST /api/admin/customers-omde/non-verified/list listNonVerified (:49) @HttpCode(200), persons.list.client.omde (:47-48) TableQueryDto
POST /api/admin/customers-omde/archived/list listArchived (:56) @HttpCode(200), persons.list.client.omde (:54-55) TableQueryDto
POST /api/admin/customers-omde/single/list listSingle (:64) @HttpCode(200), persons.list.client.single (:62-63), @CurrentAdmin() (branch scoping :65) TableQueryDto
POST /api/admin/customers-omde/single createSingle (:72) @HttpCode(200), persons.create (:70-71) Record<string, unknown>
GET /api/admin/customers-omde/query/reagent queryReagents (:82) @RequirePermission('persons.create') (:81) —
POST /api/admin/customers-omde/wallet/charge chargeWallet (:90) @HttpCode(200), @RequirePermission('wallet') (:88-89) inline { customer_id, value }
POST /api/admin/customers-omde/create create (:97) @HttpCode(200), persons.create (:95-96) Record<string, unknown>
PUT /api/admin/customers-omde/:id update (:103) persons.list.client.omde.edit (:102) Record<string, unknown>
POST /api/admin/customers-omde/reagent/list listReagents (:111) @HttpCode(200), persons.list.sellers (:109-110) TableQueryDto
GET /api/admin/customers-omde/reagent/:id reagentDetail (:118) persons.list.sellers.view (:117) —
GET /api/admin/customers-omde/:id/orders customerOrders (:125) persons.list.client.omde.view.orders (:124) —
GET /api/admin/customers-omde/:id/documents customerDocuments (:132) persons.list.client.omde.view.documents (:131) —
GET /api/admin/customers-omde/:id/wallet customerWallet (:139) @RequirePermission('wallet') (:138) —
GET /api/admin/customers-omde/dashboard/:id dashboard (:145) persons.list.client.omde.view (:144) —
GET /api/admin/customers-omde/archive/:id toggleArchive (:151) persons.list.client.omde.edit (:150) —
POST /api/admin/customers-omde/toggle-active toggleActive (:158) @HttpCode(200), persons.list.client.omde.edit (:156-157) inline { id }
POST /api/admin/customers-omde/toggle-sts toggleSts (:165) @HttpCode(200), persons.list.client.omde.edit (:163-164) inline { id }
POST /api/admin/customers-omde/verify verify (:172) @HttpCode(200), persons.list.client.omde.edit (:170-171) inline { customer_id, sts_verify_step_one?, sts_verify_step_two? } (:174)
POST /api/admin/customers-omde/credit updateCredit (:182) @HttpCode(200), persons.list.client.omde.edit (:180-181) inline { id, gram_level?, percent? }
GET /api/admin/customers-omde/:id findOne (:188) persons.list.client.omde.view (:187) —

4.7 AdminCommentsController — @Controller('admin/comments') (admin-comments.controller.ts:15)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/comments/list list (:22) @HttpCode(200), @RequirePermission('comment') (:20-21) TableQueryDto
GET /api/admin/comments/status-options statusOptions (:28) comment (:27) —
POST /api/admin/comments/sts changeStatus (:35) @HttpCode(200), comment (:33-34) inline { id: string; sts: number }
POST /api/admin/comments create (:42) @HttpCode(200), comment (:40-41) inline { product_id, customer_id, comment, reply_to? } (:43)
GET /api/admin/comments/:id findOne (:50) comment (:49) —
DELETE /api/admin/comments/:id remove (:56) comment (:55) —

4.8 AdminProductsController — @Controller('admin/products') (admin-products.controller.ts:18)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/products/list list (:25) @HttpCode(200), product.list (:23-24) TableQueryDto
POST /api/admin/products/variants/list listVariants (:32) @HttpCode(200), product.list (:30-31) TableQueryDto & { product_id? }
POST /api/admin/products/by-ids byIds (:44) @HttpCode(200), product.list (:42-43) inline { products_id: string[] }
POST /api/admin/products/variants/create createVariant (:55) @HttpCode(200), order.create (:53-54) inline { product_id, title, grams }
GET /api/admin/products/show-type-options showTypeOptions (:61) product.list (:60) —
GET /api/admin/products/brands brands (:67) product.list (:66) —
POST /api/admin/products/brands-table brandsTable (:75) @HttpCode(200), product.list (:73-74) TableQueryDto
GET /api/admin/products/collections collections (:82) product.list (:81) —
POST /api/admin/products/change-percent/count changePercentCount (:89) @HttpCode(200), product.percent (:87-88) inline { brand_id?, category_id? }
POST /api/admin/products/change-percent/apply changePercent (:96) @HttpCode(200), product.percent (:94-95) inline { percent, brand_id?, category_id? }
POST /api/admin/products/create create (:103) @HttpCode(200), product.create (:101-102) Record<string, unknown>
POST /api/admin/products/delete/:id remove (:110) @HttpCode(200), product.list.edit (:108-109) —
GET /api/admin/products/:id/variants variantsOf (:116) product.list (:115) —
GET /api/admin/products/:id findOne (:122) product.list (:121) —
PUT /api/admin/products/:id update (:128) product.list.edit (:127) Record<string, unknown>

4.9 AdminCategoriesController — @Controller('admin/categories') (admin-categories.controller.ts:14)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/categories/list list (:21) @HttpCode(200), collection.list (:19-20) TableQueryDto
GET /api/admin/categories/tree tree (:27) collection.list (:26); @Query('type') —
GET /api/admin/categories/type-options typeOptions (:33) collection.list (:32) —
POST /api/admin/categories/collections/list listCollections (:41) @HttpCode(200), collection.list (:39-40) TableQueryDto
POST /api/admin/categories/collections createCollection (:48) @HttpCode(200), collection.create (:46-47) inline { title, description?, is_active? }
PUT /api/admin/categories/collections/:id updateCollection (:53) collection.list.edit (:52) inline { title?, description?, is_active? }
DELETE /api/admin/categories/collections/:id removeCollection (:63) collection.list.edit (:62) —
POST /api/admin/categories create (:71) @HttpCode(200), collection.create (:69-70) Record<string, unknown>
GET /api/admin/categories/:id findOne (:77) collection.list (:76) —
PUT /api/admin/categories/:id update (:83) collection.list.edit (:82) Record<string, unknown>
DELETE /api/admin/categories/:id remove (:89) collection.list.edit (:88) —

4.10 AdminDocumentsController — @Controller('admin/documents') (admin-documents.controller.ts:17)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/documents/list list (:24) @HttpCode(200), document.list (:22-23) TableQueryDto
GET /api/admin/documents/type-options typeOptions (:30) document.list (:29) —
GET /api/admin/documents/query/customer queryCustomers (:36) document.create (:35) —
POST /api/admin/documents/wallet/list listWallet (:43) @HttpCode(200), wallet (:41-42) TableQueryDto
GET /api/admin/documents/wallet/config getWalletConfig (:50) config.wallet (:49) —
POST /api/admin/documents/wallet/config setWalletConfig (:57) @HttpCode(200), config.wallet (:55-56) Record<string, unknown>
GET /api/admin/documents/wallet/birthday/config getBirthdayConfig (:63) config.wallet (:62) —
POST /api/admin/documents/wallet/birthday/config setBirthdayConfig (:70) @HttpCode(200), config.wallet (:68-69) Record<string, unknown>
POST /api/admin/documents create (:77) @HttpCode(200), document.create (:75-76), @CurrentAdmin() Record<string, unknown>
POST /api/admin/documents/:id/confirm toggleConfirm (:85) @HttpCode(200), document.list.edit (:83-84), @CurrentAdmin() —
GET /api/admin/documents/:id findOne (:91) document.list (:90) —
PUT /api/admin/documents/:id update (:97) document.list.edit (:96) Record<string, unknown>
DELETE /api/admin/documents/:id remove (:103) document.list.edit (:102) —

4.11 AdminPromotionsController — @Controller('admin/promotions') (admin-promotions.controller.ts:12)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/promotions/list list (:19) @HttpCode(200), offers.list (:17-18) TableQueryDto
GET /api/admin/promotions/scope-options scopeOptions (:25) offers.list (:24) —
GET /api/admin/promotions/usage-options usageOptions (:31) offers.list (:30) —
GET /api/admin/promotions/auto getAutoPromotion (:37) offers.autopromotion (:36) —
POST /api/admin/promotions/auto setAutoPromotion (:44) @HttpCode(200), offers.autopromotion (:42-43) inline { state?, discount_percent? }
GET /api/admin/promotions/exclusions listExclusions (:51) offersIgnore (:50) —
POST /api/admin/promotions/exclusions createExclusion (:58) @HttpCode(200), offersIgnore (:56-57) inline { type: number; item_id?: string[] }
DELETE /api/admin/promotions/exclusions/:id removeExclusion (:64) offersIgnore (:63) —
POST /api/admin/promotions create (:72) @HttpCode(200), offers.create (:70-71) Record<string, unknown>
GET /api/admin/promotions/:id findOne (:78) offers.list (:77) —
PUT /api/admin/promotions/:id update (:84) offers.list.edit (:83) Record<string, unknown>
DELETE /api/admin/promotions/:id remove (:90) offers.list.edit (:89) —

4.12 AdminUsersController — @Controller('admin/users') (admin-users.controller.ts:16)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/users/list list (:23) @HttpCode(200), user.list (:21-22) TableQueryDto
GET /api/admin/users/role-options roleOptions (:29) user.list (:28) —
GET /api/admin/users/types listUserTypes (:36) user.list (:35) —
POST /api/admin/users/types createUserType (:44) @HttpCode(200), user.permission.create (:42-43) inline { val?, title? }
GET /api/admin/users/types/:ut/roles getRoles (:51) user.permission (:50) —
POST /api/admin/users/roles setRoles (:59) @HttpCode(200), user.permission.edit (:57-58) inline { ut, roles: string[] }
DELETE /api/admin/users/types/:ut/roles/:rid revokeRole (:66) user.permission.edit (:65) —
GET /api/admin/users/me myData (:72) auth only, no rid; @CurrentAdmin() (:71-72) —
POST /api/admin/users create (:79) @HttpCode(200), user.create (:77-78) Record<string, unknown>
GET /api/admin/users/:id findOne (:85) user.list (:84) —
PUT /api/admin/users/:id update (:91) user.list.edit (:90) Record<string, unknown>
DELETE /api/admin/users/:id remove (:97) user.list.remove (:96), @CurrentAdmin() —

4.13 AdminBranchesController — @Controller('admin/branches') (admin-branches.controller.ts:14)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/branches/list listBranches (:22) @HttpCode(200), branch.list (:20-21) TableQueryDto
GET /api/admin/branches/user-list userList (:28) branch.create (:27) —
POST /api/admin/branches createBranch (:35) @HttpCode(200), branch.create (:33-34) Record<string, unknown>
GET /api/admin/branches/active/:id toggleBranchActive (:41) branch.list (:40) —
GET /api/admin/branches/:id findBranch (:47) branch.list (:46) —
PUT /api/admin/branches/:id updateBranch (:53) branch.list (:52) Record<string, unknown>

4.14 AdminInvoicesController — @Controller('admin/invoices') (admin-invoices.controller.ts:15)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/invoices/list list (:22) @HttpCode(200), invoice.list (:20-21) TableQueryDto
GET /api/admin/invoices/branch-list branchList (:28) invoice.create (:27) —
POST /api/admin/invoices create (:35) @HttpCode(200), invoice.create (:33-34), @CurrentAdmin() inline { branch_id?, products?, note? }
GET /api/admin/invoices/:id findOne (:44) invoice.list (:43) —
PUT /api/admin/invoices/:id update (:50) invoice.list (:49) inline { branch_id?, products?, note? }
DELETE /api/admin/invoices/:id remove (:59) invoice.list (:58) —

4.15 AdminMiscController — @Controller('admin/misc') (admin-misc.controller.ts:21)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/misc/cities/list listCities (:29) @HttpCode(200), cities.list (:27-28) TableQueryDto
POST /api/admin/misc/cities createCity (:36) @HttpCode(200), cities.create (:34-35) inline { title?, name?, province? }
GET /api/admin/misc/cities/percent/:id getCityPercent (:42) cities.list (:41) —
GET /api/admin/misc/cities/percent-aux/:id getCityPercentAux (:48) cities.list (:47) —
PUT /api/admin/misc/cities/:id updateCity (:54) cities.list.view.edit (:53) Record<string, never>
DELETE /api/admin/misc/cities/:id removeCity (:60) cities.list.remove (:59) —
POST /api/admin/misc/sms/list listSms (:68) @HttpCode(200), sms.list (:66-67) TableQueryDto
POST /api/admin/misc/sms sendSms (:75) @HttpCode(200), sms.create (:73-74), @CurrentAdmin() inline { type?, mobile?, text? }
POST /api/admin/misc/notify/list listNotifications (:86) @HttpCode(200), notify.me (:84-85) TableQueryDto
POST /api/admin/misc/notify createNotification (:93) @HttpCode(200), notify.create (:91-92), @CurrentAdmin() inline { title?, text?, body?, type? }
POST /api/admin/misc/notify/customer notifyCustomer (:103) @HttpCode(200), notify.create (:101-102), @CurrentAdmin() inline { customer_id?, title?, text?, body? }
GET /api/admin/misc/notify/read/:id markRead (:112) notify.me (:111) —
GET /api/admin/misc/notify/:id findNotification (:118) notify.me (:117) —
POST /api/admin/misc/newsletter/list listNewsletter (:126) @HttpCode(200), newsletters (:124-125) TableQueryDto
GET /api/admin/misc/log/customers logCustomers (:133) statics (:132) —
GET /api/admin/misc/log/customer/:id customerLog (:139) statics (:138) —
GET /api/admin/misc/dashboard-links dashboardLinks (:146) config.report (:145) —
GET /api/admin/misc/const listConsts (:152) config.statics (:151) —
POST /api/admin/misc/brands createBrand (:159) @HttpCode(200), config.statics.brand.create (:157-158) inline { title }
PUT /api/admin/misc/brands/:id updateBrand (:165) config.statics.brand.edit (:164) inline { title }
DELETE /api/admin/misc/brands/:id removeBrand (:171) config.statics.brand.remove (:170) —
GET /api/admin/misc/const/slider getSlider (:186) custom.config.statics.slider (:185) —
PUT /api/admin/misc/const/slider setSlider (:192) custom.config.statics.slider (:191) Record<string, unknown>
DELETE /api/admin/misc/const/slider removeSlider (:198) custom.config.statics.slider (:197) —
GET /api/admin/misc/const/:key getConst (:204) config.statics (:203); assertNotSliderKey (:205,229-233) —
PUT /api/admin/misc/const/:key setConst (:211) config.statics (:210); assertNotSliderKey Record<string, unknown>, reads body.val fallback (:214)
DELETE /api/admin/misc/const/:key removeConst (:219) config.statics (:218); assertNotSliderKey —

4.16 PublicSliderController — @Public() @Controller('home') (public-slider.controller.ts:16-17)

METHOD Full path Handler Guards/decorators DTO
GET /api/home/slider getSlider (:22) @Public() class-level (:16) —

4.17 AdminUploadsController — @Controller('admin/files') (admin-uploads.controller.ts:45)

METHOD Full path Handler Guards/decorators DTO
POST /api/admin/files/create create (:52) @HttpCode(200), @UseInterceptors(FileInterceptor('file')) (:50-51), @UploadedFile(), @Body('purpose'), @CurrentAdmin(); auth required, no rid (rationale :38-44) multipart file + purpose (no DTO class)
GET /api/admin/files/:id findOne (:61) auth only, no rid (:60) —

4.18 PublicUploadsController — @Public() @Controller('uploads') (public-uploads.controller.ts:14-15)

METHOD Full path Handler Guards/decorators DTO
GET /api/uploads/:filename stream (:19) @Public() class-level (:14); @Header('Cache-Control','public, max-age=86400') (:18) —

4.19 AdminDashboardController — @Controller('admin/dashboard') (admin-dashboard.controller.ts:15)

METHOD Full path Handler Guards/decorators DTO
GET /api/admin/dashboard/overview overview (:21) dashboard.view (:20) —
GET /api/admin/dashboard/omde omde (:28) — deprecated alias dashboard.view (:27) —
GET /api/admin/dashboard/tak tak (:34) — deprecated alias dashboard.view (:33) —
GET /api/admin/dashboard/report/order-item orderItemReport (:42) report.order.item (:41); @Query('start_date'/'end_date') —
GET /api/admin/dashboard/report/product productReport (:49) report.product (:48); @Query(...) —

4.20 CatalogController — @Public() @Controller('catalog/products') (catalog.controller.ts:5-6)

METHOD Full path Handler Guards/decorators DTO
GET /api/catalog/products list (:11) @Public() class-level (:5) —

4.21 CustomersController — @Public() @Controller('customers') (customers.controller.ts:5-6)

METHOD Full path Handler Guards/decorators DTO
GET /api/customers list (:11) @Public() class-level —
GET /api/customers/:id get (:16) @Public() —
GET /api/customers/:id/orders getOrders (:21) @Public() —
GET /api/customers/:id/ledger-entries getLedgerEntries (:26) @Public() —

4.22 SalesAgentController — @Controller('sales-agent') (sales-agent.controller.ts:21)

METHOD Full path Handler Guards/decorators DTO
GET /api/sales-agent/me me (:26) auth required, no rid; identity-scoped in service (role==='SALES_EXPERT', sales-agent.service.ts:36-40) —
GET /api/sales-agent/my-customers myCustomers (:31) auth, no rid; @Query('search') —
POST /api/sales-agent/verify-acting-as verifyActingAs (:37) @HttpCode(200) (:36); auth, no rid inline { customerId }

4.23 OrdersController — @Public() @Controller('orders') (orders.controller.ts:7-8)

METHOD Full path Handler Guards/decorators DTO
POST /api/orders create (:13) @Public() class-level CreateOrderDto
GET /api/orders/:id get (:18) @Public() —
PATCH /api/orders/:id/status updateStatus (:23) @Public() UpdateOrderStatusDto (orders/dto)

4.24 AccountingController — @Public() @Controller('accounting') (accounting.controller.ts:7-8)

METHOD Full path Handler Guards/decorators DTO
GET /api/accounting/customers/:customerId/balance getBalance (:13) @Public() class-level —
POST /api/accounting/customers/:customerId/reconcile reconcile (:18) @Public() —
POST /api/accounting/repayments/gold postGoldRepayment (:23) @Public() PostGoldRepaymentDto
POST /api/accounting/repayments/rial postRialRepayment (:28) @Public() PostRialRepaymentDto
POST /api/accounting/entries/:entryId/confirm confirmEntry (:33) @Public() —

4.25 PricingController — @Public() @Controller('pricing') (pricing.controller.ts:5-6)

METHOD Full path Handler Guards/decorators DTO
GET /api/pricing/latest getLatest (:11) @Public(); @Query('karatPerMille'), defaults 750 (:12) —

4.26 Endpoint totals

Controller Routes
AuthController 2
AdminOrdersController 9
AdminCitiesController 1
AdminPreOrdersController 5
AdminWorkshopsController 5
AdminCustomersController 23
AdminCommentsController 6
AdminProductsController 15
AdminCategoriesController 11
AdminDocumentsController 13
AdminPromotionsController 12
AdminUsersController 12
AdminBranchesController 6
AdminInvoicesController 6
AdminMiscController 27
PublicSliderController 1
AdminUploadsController 2
PublicUploadsController 1
AdminDashboardController 5
CatalogController 1
CustomersController 4
SalesAgentController 3
OrdersController 3
AccountingController 5
PricingController 1
TOTAL 179

Unauthenticated (@Public()) surface — 15 routes: all of AccountingController (5), OrdersController (3), CustomersController (4), CatalogController (1), PricingController (1), PublicSliderController (1), PublicUploadsController (1). Note /api/accounting/* writes to the ledger and /api/orders creates gold-debt-bearing orders while being fully public (accounting.controller.ts:7, orders.controller.ts:7).


5. DTOs (every class, fields, class-validator rules)

Only 6 DTO files / 8 DTO classes exist. Most admin routes take untyped Record<string, unknown> or inline object literals (no validation beyond the global whitelist pipe, which strips nothing on a plain Record type).

src/modules/auth/dto/login.dto.ts — LoginDto

Field Type Validators Cite
phone string @IsString(), @IsNotEmpty() :11-13
password string @IsString(), @IsNotEmpty() :15-17

src/modules/accounting/dto/post-gold-repayment.dto.ts — PostGoldRepaymentDto

Field Type Validators Cite
customerId string @IsString() :4-5
instrument 'MELTED_GOLD'|'TRANSFER'|'CHEQUE'|'CASH' @IsIn([...]) :7-8
rawValue number @IsNumber(), @IsPositive() :10-12
karatPerMille? number @IsOptional(), @IsNumber() :14-16
pricePerGramRial? number @IsOptional(), @IsNumber(), @IsPositive() :18-21
note? string @IsOptional(), @IsString() :23-25

src/modules/accounting/dto/post-rial-repayment.dto.ts — PostRialRepaymentDto

Field Type Validators Cite
customerId string @IsString() :4-5
amount number @IsNumber(), @IsPositive() :7-9
note? string @IsOptional(), @IsString() :11-13

src/modules/orders/dto/create-order.dto.ts — CreateOrderItemInputDto

Field Type Validators Cite
productVariantId string @IsString() :6-7
quantity number @IsInt(), @Min(1) :9-11
wagePercent number @IsNumber() (no min/max — negative or >100 accepted) :14-15
discountPercent? number @IsOptional(), @IsNumber() :17-19
flatRialCharge? number @IsOptional(), @IsNumber() :22-24

src/modules/orders/dto/create-order.dto.ts — CreateOrderDto

Field Type Validators Cite
customerId string @IsString() :28-29
branchId? string @IsOptional(), @IsString() :31-33
channel? OrderChannel @IsOptional(), @IsEnum(OrderChannel); defaults RETAIL in service :40-42
items CreateOrderItemInputDto[] @IsArray(), @ArrayMinSize(1), @ValidateNested({each:true}), @Type(()=>CreateOrderItemInputDto) :44-48

src/modules/orders/dto/update-order-status.dto.ts — UpdateOrderStatusDto

Field Type Validators Cite
status OrderStatus @IsEnum(OrderStatus) :5-6
note? string @IsOptional(), @IsString() :8-10
actorAdminUserId? string @IsOptional(), @IsString() — client-supplied actor id on a @Public() route :12-14

src/modules/admin-orders/update-status.dto.ts — UpdateOrderStatusDto (distinct class, same name)

Field Type Validators Cite
orderId string @IsString() :12-13
status OrderStatus @IsEnum(OrderStatus) :15-16
note? string @IsOptional(), @IsString() :18-20
remove? boolean @IsOptional(), @IsBoolean() — true = delete that status event :23-25

src/modules/admin-table/table-query.dto.ts — TableFilterDto

Field Type Validators Cite
id? number|string @IsOptional() (accepted, ignored) :22-23
field? string @IsOptional(), @IsString() :25-27
op? string @IsOptional(), @IsIn(['=','!=','>','>=','<','<=','like','in','between','null','notnull']) :32-34
value? unknown @IsOptional() :36-37
value2? unknown @IsOptional() (used by between) :39-40

src/modules/admin-table/table-query.dto.ts — TableQueryDto

Field Type Validators Cite
search? string @IsOptional(), @IsString() :51-53
filters? TableFilterDto[] @IsOptional(), @IsArray(), @ValidateNested({each:true}), @Type(()=>TableFilterDto) :55-59
page? number @IsOptional(), @Type(()=>Number), @IsInt(), @Min(1) :61-65
limit? number @IsOptional(), @Type(()=>Number), @IsInt(), @Min(1), @Max(500) :67-72
order? string @IsOptional(), @IsString() — '+field' asc / '-field' desc :75-77
select? string[] @IsOptional(), @IsArray() :80-82
titles? Record<string,string> @IsOptional() :85-86
report_title? string @IsOptional(), @IsString() :88-90
op? 'excel'|'json'|'print' @IsOptional(), @IsIn([...]) — always rejected by assertNotExport :97-99
total? number @IsOptional() (echoed back) :102-103

6. Prisma schema (prisma/schema.prisma, 911 lines)

Generator: prisma-client-js, binaryTargets = ["native","debian-openssl-3.0.x"] (schema.prisma:6-10). Datasource: postgresql, url from env("DATABASE_URL") (schema.prisma:12-15). Header note: "All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math" (schema.prisma:4).

6.1 Enums (14)

Enum Values Cite
AdminRole SUPER_ADMIN, SYSTEM_APPROVER, SALES_EXPERT, WORKSHOP_SUPERVISOR, ACCOUNTANT :21-27
CustomerKind SALES_AGENT (legacy-only), APP_SHOPKEEPER, WALK_IN_SHOPKEEPER, BANKER, WORKSHOP_SUPERVISOR, RETAIL :45-52
VerificationStep UNVERIFIED, STEP_ONE_SUBMITTED, STEP_TWO_SUBMITTED, VERIFIED :54-59
OrderChannel RETAIL, WHOLESALE :64-67
OrderStatus SUSPENDED, PLACED, ADMIN_APPROVED, SOURCING, PREPARING, DELIVERED_TO_AGENT, DELIVERED_TO_CUSTOMER, CANCELLED :69-78
PreOrderStatus PLACED, ADMIN_APPROVED, WORKSHOP_APPROVED, READY_TO_SHIP, DELIVERED, CANCELLED :80-87
LedgerCurrency GOLD_GRAM, RIAL :89-92
LedgerDirection DEBIT (increases owed), CREDIT (decreases owed) :94-97
LedgerSourceType ORDER_STOCK, ORDER_PRE_ORDER, REPAYMENT_MELTED_GOLD, REPAYMENT_TRANSFER, REPAYMENT_CHEQUE, REPAYMENT_CASH, WALLET_BONUS, ADJUSTMENT :99-108
ProductImageRole FEATURED, FEATURED_MOBILE, GALLERY, GALLERY_MOBILE :367-372
CommentStatus PENDING, APPROVED, REJECTED :655-659
PromotionScope PRODUCT, CATEGORY, CUSTOMER, BRAND :704-709
PromotionUsage ONCE, UNTIL_END_DATE :712-715
MessageAudience SINGLE_NUMBER, MULTIPLE_NUMBERS, ALL_CUSTOMERS, WHOLESALE_CUSTOMERS, RETAIL_CUSTOMERS, SALES_AGENTS :805-812

6.2 Models (29)

City (:114-126)

Field Type Default / Attrs
id String @id @default(cuid())
name String —
province String? —
percent Decimal? @db.Decimal(6,3) — per-city wage surcharge (:120)
percentAux Decimal? @db.Decimal(6,3) (:121)
customers/addresses/branches/preOrders relations Customer[], CustomerAddress[], Branch[], PreOrder[] (:122-125)

AdminUser (:128-170)

Field Type Default / Attrs
id String @id @default(cuid())
fullName String —
mobile String @unique (:131)
passwordHash String —
role AdminRole —
userTypeId Int? FK → UserType (:137-138)
branchId String? FK → Branch (:139-140)
wagePercent Decimal? @db.Decimal(6,3) — commission on referred orders (:141)
isActive Boolean @default(true) (:142)
createdAt DateTime @default(now()) (:143)
avatarFileId String? FK → UploadedFile (relation AdminUserAvatar) (:150-151)
address, storeName, pasazhName, memberCode String? legacy sales-agent fields (:152-155)
Relations referredCustomers (ReferralAgent), supervisedWorkshops, branchAllocations, postedLedgerEntries, orderStatusEvents, preOrderStatusEvents, sentSmsMessages, sentNotifications, uploadedFiles :157-165
Indexes @@index([role]), @@index([userTypeId]), @@index([avatarFileId]) :167-169

Customer (:172-238)

Field Type Default / Attrs
id String @id @default(cuid())
fullName String —
mobile String @unique (:175)
nationalCode String @unique (:176)
passwordHash String —
cityId / city String? / City? (:178-179)
kind CustomerKind @default(APP_SHOPKEEPER) (:183)
phone, address, storeName, pasazhName, zone, memberCode String? (:187-193)
referredByAgentId String? FK → AdminUser (ReferralAgent) (:195-196)
goldCreditLimitGrams Decimal @default(0) @db.Decimal(14,4) — legacy gram_level (:200)
goldDebtGrams Decimal @default(0) @db.Decimal(14,4) — legacy remain (cache) (:201)
fiatDebtRial Decimal @default(0) @db.Decimal(20,2) — legacy rial_remain (cache) (:202)
verificationStep VerificationStep @default(UNVERIFIED) (:204)
nationalIdImageUrl, businessLicenseUrl, shopImageUrl String? KYC docs (:205-207)
avatarFileId String? FK → UploadedFile (CustomerAvatar) (:212-213)
canPlaceOrders Boolean @default(true) — legacy sts (:219)
isActive Boolean @default(true) — legacy active (:221)
isArchived Boolean @default(false) (:223)
createdAt / updatedAt DateTime @default(now()) / @updatedAt (:224-225)
Relations addresses, orders, preOrders, ledgerEntries, walletBonuses, comments, promotions, notifications, activityLogs :227-235
Index @@index([referredByAgentId]) :237

CustomerAddress (:240-248)

id (cuid PK), customerId→Customer, cityId?→City, addressLine String, isDefault Boolean @default(false).

Branch (:250-263)

id (cuid PK), name, address?, phone?, isActive @default(true) (:257), cityId?→City, relations adminUsers/orders/allocations.

Workshop (:265-276)

id (cuid PK), name, address?, phone?, supervisorId?→AdminUser (WorkshopSupervisor), isActive @default(true), preOrders.

Category (:282-299)

id (cuid PK), title, type Int @default(1) (1=site,2=mobile), imageUrl?, iconUrl?, metaTag?, description?, parentId?→self (CategoryTree), children, products, promotions.

Brand (:301-306)

id (cuid PK), title, products, promotions.

Collection (:310-316)

id (cuid PK), title, description?, isActive @default(true), products (CollectionProduct[]).

CollectionProduct (:318-325)

collectionId→Collection, productId→Product; @@id([collectionId, productId]) (:324).

Product (:327-348)

id (cuid PK), title, description?, categoryId?→Category, brandId?→Brand, productCode String? @unique (:336), showType Int @default(3) (1 site/2 mobile/3 both) (:338), isActive @default(true), createdAt @default(now()); relations variants, images, collections, comments, promotions, allocationItems.

ProductImage (:350-365)

id (cuid PK), productId→Product, url String, sortOrder Int @default(0), role ProductImageRole @default(GALLERY) (:359), variants ProductVariant[].

ProductVariant (:374-402)

Field Type Default / Attrs
id String cuid PK
productId String → Product
title String —
sku String? @unique (:379)
rashWeightGrams Decimal @db.Decimal(14,4) — raw weight before making charge (:381)
defaultWagePercent Decimal @db.Decimal(6,3) (:383)
qty Int @default(0) (:387)
isPreOrder Boolean @default(false) (:389)
isActive Boolean @default(true) (:390)
imageId String? → ProductImage, onDelete: SetNull (:396-397)
Index @@index([imageId]) :401

Order (:408-439)

Field Type Default / Attrs
id String cuid PK
orderNumber String @unique (:410)
customerId String → Customer (:411-412)
branchId String? → Branch (:413-414)
channel OrderChannel @default(RETAIL) (:416)
status OrderStatus @default(PLACED) (:417)
totalGoldWeightGrams Decimal @db.Decimal(14,4) — incl. wage (:420)
wageWeightGrams Decimal @db.Decimal(14,4) — pure making-charge component (:422)
flatRialCharge Decimal @default(0) @db.Decimal(20,2) — legacy rial_input (:424)
isSuspendedOverCreditLimit Boolean @default(false) (:426)
deliveredAt DateTime? setting it triggers debt recognition (:427)
createdAt/updatedAt DateTime @default(now()) / @updatedAt (:429-430)
Relations items, statusEvents, ledgerEntries, convertedFromPreOrder :432-435
Indexes @@index([customerId]), @@index([status]) :437-438

OrderItem (:441-456)

Field Type Default / Attrs
id String cuid PK
orderId / productVariantId String → Order / ProductVariant
quantity Int —
rashWeightGramsSnapshot Decimal @db.Decimal(14,4) (:450)
wagePercentSnapshot Decimal @db.Decimal(6,3) (:451)
discountPercentSnapshot Decimal @default(0) @db.Decimal(6,3) (:452)
computedWeightGrams Decimal @db.Decimal(14,4) = rash*(1+(wage-discount)/100) (:453-454)
flatRialCharge Decimal @default(0) @db.Decimal(20,2) (:455)

OrderStatusEvent (:458-470)

id (cuid PK), orderId→Order, fromStatus OrderStatus?, toStatus OrderStatus, note?, actorAdminUserId?→AdminUser, createdAt @default(now()); @@index([orderId]) (:469).

PreOrder (:476-505)

id (cuid PK), customerId→Customer, cityId?→City, workshopId?→Workshop, status PreOrderStatus @default(PLACED) (:484), designImageUrl?, colorName?, sizeSpec?, stoneColor?, minaColor?, estimatedWeightGrams Decimal? @db.Decimal(14,4) (:491), estimatedPriceRial Decimal? @db.Decimal(20,2) (:492), description?, convertedOrderId String? @unique →Order (:496-497), createdAt/updatedAt, statusEvents; @@index([customerId]) (:504).

PreOrderStatusEvent (:507-519)

id (cuid PK), preOrderId→PreOrder, changes Json (field-level diff, :512), actorAdminUserId?→AdminUser, ipAddress?, createdAt; @@index([preOrderId]) (:518).

LedgerEntry (:525-571) — core accounting row

Field Type Default / Attrs
id String cuid PK
customerId String → Customer (:527-528)
currency LedgerCurrency (:530)
direction LedgerDirection (:531)
sourceType LedgerSourceType (:532)
amount Decimal @db.Decimal(20,4) — always positive; sign from direction (:533-534)
karatPerMille Int? purity for melted gold (:537)
pricePerGramRialAtEntry Decimal? @db.Decimal(20,2) — for CASH (:539)
orderId String? → Order (:541-542)
confirmed Boolean @default(false) — pending entries excluded from cached balance (:545)
confirmedAt DateTime? (:546)
postedByAdminUserId String? → AdminUser (:547-548)
labName, hallmark, accountName, bankName, transferTo String? instrument metadata (:555-559)
originalValue Decimal? @db.Decimal(14,4) — pre-karat-normalization weight (:561)
priceRial Decimal? @db.Decimal(20,2) (:563)
receivedAt DateTime? (:564)
note String? (:566)
createdAt DateTime @default(now()) (:567)
Indexes @@index([customerId, currency]), @@index([orderId]) :569-570

GoldPriceQuote (:574-582)

id (cuid PK), karatPerMille Int, pricePerGramRial Decimal @db.Decimal(20,2) (:577), source String, quotedAt DateTime @default(now()) (:579); @@index([karatPerMille, quotedAt]) (:581).

WalletBonus (:585-597)

id (cuid PK), customerId→Customer, orderId String? (no FK relation declared, :589), goldGrams Decimal @db.Decimal(14,4) (:590), grantedAt @default(now()), expiresAt DateTime (required) (:592), consumedAt DateTime?, isActive Boolean @default(true); @@index([customerId, isActive]) (:596).

UserType (:620-632)

id Int @id (legacy ut, not cuid) (:623), title String, isActive @default(true), createdAt @default(now()); relations permissions, adminUsers; @@map("user_type") (:631).

Permission (:634-644)

rid String (dotted id), userTypeId Int → UserType onDelete: Cascade (:638); @@id([rid, userTypeId]) composite PK (:641), @@index([userTypeId]) (:642), @@map("permission") (:643).

Comment (:661-681)

id (cuid PK), productId→Product, customerId→Customer, body String, status CommentStatus @default(PENDING) (:668), replyToId?→self (CommentReplies), replies, createdAt/updatedAt; @@index([productId]), @@index([customerId]), @@index([status]) (:678-680).

Setting (:690-694)

key String @id, value Json, updatedAt @updatedAt. Replaces legacy config_static.

Promotion (:717-746)

id (cuid PK), title, description?, scope PromotionScope @default(PRODUCT) (:721), usage PromotionUsage @default(ONCE) (:722), discountPercent Decimal @db.Decimal(6,3) (:724), isActive @default(true), platform Int @default(3) (:727), startsAt?/endsAt?, and exactly one of productId/categoryId/customerId/brandId per scope (:733-740), createdAt/updatedAt; @@index([scope, isActive]) (:745).

PromotionExclusion (:752-760)

id (cuid PK), type Int, itemId String, createdAt; @@unique([type, itemId]) (:758), @@index([type]) (:759).

BranchAllocation (:769-784)

id (cuid PK), branchId→Branch, allocatorAdminUserId?→AdminUser (AllocationAuthor), note?, createdAt, items; @@index([branchId]) (:783).

BranchAllocationItem (:786-796)

id (cuid PK), allocationId→BranchAllocation onDelete: Cascade (:789), productId→Product, weightGrams Decimal? @db.Decimal(14,4) (:793); @@unique([allocationId, productId]) (:795).

SmsMessage (:814-829)

id (cuid PK), audience MessageAudience, recipients String? (comma-separated), body String, sentCount Int @default(0) (:821), sentByAdminUserId?→AdminUser (SmsAuthor), createdAt; @@index([createdAt]) (:828).

Notification (:831-845)

id (cuid PK), title, body, audience MessageAudience @default(ALL_CUSTOMERS) (:835), sentByAdminUserId?→AdminUser (NotificationAuthor), createdAt, recipients; @@index([createdAt]) (:844).

NotificationRecipient (:848-858)

id (cuid PK), notificationId→Notification onDelete: Cascade (:851), customerId→Customer, readAt DateTime?; @@unique([notificationId, customerId]) (:856), @@index([customerId, readAt]) (:857).

NewsletterSubscriber (:861-869)

id (cuid PK), email String?, mobile String?, createdAt; @@unique([email]), @@unique([mobile]) (:867-868).

CustomerActivityLog (:873-883)

id (cuid PK), customerId→Customer, action String (free-form key), detail String?, createdAt; @@index([customerId, createdAt]) (:882).

UploadedFile (:892-911)

id (cuid PK), url String, originalName, mimeType, sizeBytes Int, purpose String?, uploadedByAdminUserId?→AdminUser (FileUploader), createdAt, customerAvatars/adminUserAvatars back-relations; @@index([purpose]) (:910).

6.3 Decimal precision map (no Float anywhere in the schema)

Precision Used for Examples
Decimal(14,4) gold weights in grams Customer.goldCreditLimitGrams/goldDebtGrams (:200-201), Order.totalGoldWeightGrams/wageWeightGrams (:420,422), OrderItem.rash/computedWeight (:450,454), ProductVariant.rashWeightGrams (:381), WalletBonus.goldGrams (:590), LedgerEntry.originalValue (:561), PreOrder.estimatedWeightGrams (:491), BranchAllocationItem.weightGrams (:793)
Decimal(20,2) Rial money Customer.fiatDebtRial (:202), Order.flatRialCharge (:424), OrderItem.flatRialCharge (:455), GoldPriceQuote.pricePerGramRial (:577), LedgerEntry.pricePerGramRialAtEntry/priceRial (:539,563), PreOrder.estimatedPriceRial (:492)
Decimal(20,4) ledger amount (dual-currency, so finer than money) LedgerEntry.amount (:534)
Decimal(6,3) percentages City.percent/percentAux (:120-121), AdminUser.wagePercent (:141), ProductVariant.defaultWagePercent (:383), OrderItem.wagePercentSnapshot/discountPercentSnapshot (:451-452), Promotion.discountPercent (:724)

7. Migrations & seed

7.1 prisma/migrations/* (in order)

# Folder / file Note
— 0002_admin_schema.sql bare .sql at the migrations root (not a folder) — sorts first lexically
1 20260910113720_first initial schema
2 20260914165255_sync_schema_drift
3 20260916141751_add_retail_customer_kind adds CustomerKind.RETAIL
4 20260917120000_customer_name_parts_and_percent
5 20260918090000_customer_parent_agent_self_relation
6 20260918120000_add_product_image_role adds ProductImageRole
7 20260921090000_add_variant_image adds ProductVariant.imageId
8 20260921120000_agent_is_admin_user sales agent moves Customer→AdminUser
— migration_lock.toml provider = "postgresql"

Custom migration runner (not prisma migrate deploy): src/prisma/migrate.ts applies the .sql files itself through the Prisma client and records them in a tracking table _goldbank_migrations (migrate.ts:5,7-13). Reason: the release image is copy-only and has no Prisma CLI (migrate.ts:10-12). Migrations dir resolved from MIGRATIONS_DIR or one of 4 fallbacks (migrate.ts:20-36). SQL is split statement-by-statement because Postgres rejects multi-statement parameterised queries (migrate.ts:38-42, splitSqlStatements). Invoked from prepareDatabase() before Nest boots (bootstrap.ts:104-134, main.ts:19).

7.2 prisma/seed.ts (738 lines) — what it creates

Entity What Cite
Dev passwords DEV_PASSWORD = 'admin1234', bcrypt cost 10; DEV_TEST_PHONE = '09128081392' whose password is the phone number itself (dev-only) seed.ts:16-24
Cities (4) تهران / مشهد / اصفهان / تبریز, fixed ids city-tehran etc. :33-38
Branches (2) Tehran, Mashhad :41-49
Workshop (1) upsert :51
UserTypes + Permissions (5) see table below :66-110
AdminUsers (6 upserts) dev test user 09128081392 (SUPER_ADMIN, ut 85, branchId null); 09120000001 مدیر ارشد سیستم (SUPER_ADMIN, ut 85, branchId null — deliberately branch-less so head office is not branch-scoped, :140-143); 09121234567 رضا احمدی (SALES_EXPERT, ut 86, Tehran, wagePercent 1.5); 09129876543 سارا کریمی (SALES_EXPERT, ut 86, Mashhad, wagePercent 1.2); 09123334444 هومن یزدانی (WORKSHOP_SUPERVISOR, ut 88); 09125556666 فرزانه محمودی (ACCOUNTANT, ut 87, branchId null) :118-194
Customers customerData array, upserted with PLACEHOLDER_PASSWORD_HASH :197-333
Categories (5) انگشتر، دستبند، گردنبند، گوشواره، سکه و شمش :341-347
Brands (2) Montzar, Artisan :349-358
Products + variants + images from productDefs; placeholder image /img/no-items.png :29, 360-462
Collection featured + 3 CollectionProduct rows :465-484
GoldPriceQuote { karatPerMille: 750, pricePerGramRial: '61500000', source: 'seed-illustrative' } — create, not upsert, so a re-seed appends another quote :487-489
Order ORD-SEED-0002 RETAIL, status PLACED, rash 2.400, wage 7.5% → computed via parseFloat(...).toFixed(4) (float math in the seed only) :494-527
Order ORD-SEED-0001 WHOLESALE, DELIVERED_TO_CUSTOMER, rash 3.100, wage 9%, flatRialCharge 500000, deliveredAt: now, 3 status events :542-580
Ledger entries GOLD_GRAM DEBIT ORDER_STOCK (confirmed) + RIAL DEBIT ORDER_STOCK (confirmed) + a pending GOLD_GRAM CREDIT REPAYMENT_MELTED_GOLD 1.5000 @ 750‰ (confirmed: false) to demo the confirm flow :582-625
Cached balances customer1 incremented by the posted debt (:608-611); customer2 set to goldDebt 8.2500 / fiat 2000000; customer5 zeroed :608-630
PreOrders (2) preorder-0001 PLACED, preorder-0002 ADMIN_APPROVED :635-667
Comments (3) one APPROVED, one PENDING, one REJECTED :672-700
Variant stock every variant gets qty = 5 + (i%4), isPreOrder = i%5===0 :705-711
Product codes P-0001, P-0002, … by title asc :712-718
Setting rows NONE — the seed creates no Setting rows at all. wallet.config, wallet.birthday, promotion.auto, mobile_main_slider are created lazily on first write (admin-documents.service.ts:421-428, admin-promotions.service.ts:261-282) and read back as {} / defaults until then (admin-documents.service.ts:416-419, admin-promotions.service.ts:256-259)

Seed UserTypes and their rids (seed.ts:66-98):

ut id Title rids
85 مدیر ارشد ALL_RIDS — all 144 (seed.ts:68)
86 کارشناس فروش order.list, order.create, order.omde, order.makeOrder, persons.list.client.omde, persons.list.client.omde.view, persons.list.client.single, persons.list.client.single.view, persons.create, product.list, dashboard.view, comment (12) (:72-77)
87 حسابدار document.list, document.create, document.list.edit, invoice.list, invoice.create, wallet, config.wallet, report.order.item, report.product, config.report, dashboard.view (11) (:82-86)
88 سرپرست کارگاه workshop.list, workshop.create, order.tamin, order.list, product.list (5) (:91)
89 کاربر شعبه buy.order.list, order.create, persons.create, branch.list (4) (:96)

Only leaf rids are stored; ancestors derived at runtime (seed.ts:64-65). Permissions written with createMany({ skipDuplicates: true }) (:106-109).

Idempotency: everything uses upsert except goldPriceQuote.create (:487) and the ledger/order blocks, which are guarded by a findUnique on the seed order number (:494-497, :542-543).


8. Business logic

8.1 Gold price

  • PricingService.recordQuote(karatPerMille, pricePerGramRial, source) → inserts a GoldPriceQuote (pricing.service.ts:9-13). No caller anywhere in src/ — the only write path is unused; quotes only enter via the seed.
  • PricingService.getLatestQuote(karat) → newest quote for that karat, 404 if none (pricing.service.ts:15-22).
  • GET /api/pricing/latest?karatPerMille= defaults to 750 (pricing.controller.ts:11-12).
  • No external price feed, no scheduler, no HTTP fetch — nothing refreshes quotes automatically.

8.2 Gram / weight calculation (the central formula)

OrdersService.createOrder (orders.service.ts:32-109):

  • Per line: netPercent = wagePercent - discountPercent; computedWeightGrams = rashWeight * (netPercent/100 + 1) — all Prisma.Decimal arithmetic (.sub, .div, .add, .mul), no float (orders.service.ts:39-43).
  • rashWeight is snapshotted from the variant at order time and never re-read (orders.service.ts:39, schema.prisma:449).
  • totalGoldWeightGrams = Σ computedWeight * qty (orders.service.ts:57-60); totalRashWeightGrams = Σ rash * qty (:61-64); wageWeightGrams = total - totalRash (:65).
  • flatRialCharge = Σ item.flatRialCharge * qty (:66-69).
  • No rounding / quantization is applied in code — precision is enforced only by the column types Decimal(14,4) / Decimal(20,2) at write time (schema.prisma:420-424).
  • Admin path (AdminOrdersService.createFromAdmin, admin-orders.service.ts:287-339) deliberately delegates to OrdersService.createOrder rather than recomputing; client-sent totals (total_price, total_weight, gold_price_original) are ignored and recomputed server-side (admin-orders.service.ts:283-286). Wage falls back to variant.defaultWagePercent when not negotiated (:316).

8.3 Fees / commissions / tax / spread

  • Wage (اجرت / making charge) is the only fee: a percentage on the raw weight, expressed as extra gold weight, not money (ProductVariant.defaultWagePercent, OrderItem.wagePercentSnapshot).
  • Discount: OrderItem.discountPercentSnapshot, subtracted from wage before applying.
  • Flat Rial charge: per-unit non-gold cost, e.g. stones (Order.flatRialCharge, schema.prisma:423-424).
  • Agent commission: AdminUser.wagePercent Decimal(6,3) (schema.prisma:141) — stored and displayed (sales-agent.service.ts:92) but never applied to any calculation.
  • City surcharges: City.percent / percentAux (schema.prisma:118-121), readable via GET admin/misc/cities/percent/:id and percent-aux/:id (admin-misc.controller.ts:40-50) but never used in pricing.
  • Bulk wage change: AdminProductsService.changePercent sets defaultWagePercent across a brand/category filter via updateMany, using new Prisma.Decimal(String(percent)) (admin-products.service.ts:641-657); changePercentCount previews the affected count (:630-639).
  • Tax / VAT: no tax logic anywhere.
  • Spread / buy-vs-sell price: none — a single GoldPriceQuote.pricePerGramRial per karat, no bid/ask.

8.4 Rounding & precision (Decimal vs float) — exact findings

  • Schema: zero Float fields; every monetary/weight/percent column is Decimal with explicit @db.Decimal(p,s) (see §6.3), per the stated design rule (schema.prisma:4).
  • Ledger math: entirely Prisma.Decimal — amount.lte(0), .neg(), .mul(), .div(), .sub(), .gt() (ledger.service.ts:34-35, 95, 131, 178, 189, 300-302).
  • Order math: entirely Prisma.Decimal (orders.service.ts:39-69).
  • Float leaks found (3):
    1. ledger.service.ts:189 — CASH conversion divides by GRAMS_PER_MESGHAL, a JS number literal 4.331802 (packages/shared-types/src/index.ts:9). Decimal.div(number) coerces the number, so the constant itself carries binary-float representation into an otherwise-Decimal chain. The formula is flagged in its own comment as "worth stakeholder review" (ledger.service.ts:186-187).
    2. admin-dashboard.service.ts:148 and :347 — sorting aggregates with Number(b.weight) - Number(a.weight), i.e. Decimal→float for ordering only (display, not stored).
    3. prisma/seed.ts:500, 509, 547, 560 — parseFloat(...).toFixed(4) to compute demo order weights. Seed-only; not a runtime path.
  • Explicit rounding calls: none. No Math.round, toFixed or Decimal.toDecimalPlaces in src/. All quantization is implicit at the DB column scale.
  • Karat normalization: goldGrams = raw * karatPerMille / 750, with STANDARD_KARAT_PER_MILLE = 750 (ledger.service.ts:178, packages/shared-types/src/index.ts:6).

8.5 Wallet / balance

  • Source of truth: LedgerEntry (append-only). Customer.goldDebtGrams / fiatDebtRial are denormalized caches (schema.prisma:198-202, ledger.service.ts:23-27).
  • applyToBalance: signedDelta = DEBIT ? amount : -amount, applied with Prisma { increment: signedDelta } on the matching currency field (ledger.service.ts:88-103).
  • getBalance reads the cache + credit limit (ledger.service.ts:270-282).
  • reconcileBalance recomputes from confirmed entries only: goldDebits - goldCredits, rialDebits - rialCredits, and overwrites the cache (ledger.service.ts:284-304); sumConfirmed uses aggregate._sum filtered on confirmed: true (:306-312). Documented as periodic/on-demand self-healing, unlike legacy's recompute-on-every-write (:284-288). Nothing schedules it — it only runs via POST /api/accounting/customers/:customerId/reconcile, which is @Public().
  • Wallet bonus is a separate concept from debt: WalletBonus in gold grams with a mandatory expiresAt. chargeWallet validates grams > 0 and applies a hardcoded one-year expiry because the column is required (admin-customers.service.ts:734-749, expiry at :743-744). It does not post a ledger entry, so a granted bonus never touches goldDebtGrams despite LedgerSourceType.WALLET_BONUS existing (schema.prisma:106) — that enum value has no producer in src/.
  • Wallet listings: AdminDocumentsService.listWallet (admin-documents.service.ts:369-409), AdminCustomersService.customerWallet (admin-customers.service.ts:701-714).

8.6 Buy / sell orders

  • There is no buy/sell gold trading flow. "Orders" are jewelry/product orders that incur gold-weight debt. No BUY/SELL side, no order matching, no quote-locking.
  • Order placement: OrdersService.createOrder (orders.service.ts:32-109); admin builder path AdminOrdersService.createFromAdmin (admin-orders.service.ts:287-339).
  • Order number: ORD-${Date.now()}-${random6} (orders.service.ts:148-151) — not collision-proof by construction, relies on the @unique column.
  • Order deletion refuses once ledger entries exist (admin-orders.service.ts:217-221); otherwise cascades events+items+order in one transaction (:223-228).

8.7 Deposit / withdraw / transfers

  • No customer-initiated deposit or withdrawal, and no customer-to-customer transfer. "TRANSFER" (REPAYMENT_TRANSFER) means a bank remittance repayment instrument, not an internal transfer (ledger.service.ts:193-196, schema.prisma:104).
  • The only inbound value flow is a repayment document filed by an admin (§8.9).

8.8 Order & transaction statuses and transitions

Order status labels (admin-orders.service.ts:16-25):

Enum Farsi label
SUSPENDED معلق (سقف اعتبار)
PLACED ثبت شده
ADMIN_APPROVED تایید مدیر
SOURCING در حال تامین
PREPARING در حال آماده‌سازی
DELIVERED_TO_AGENT تحویل به نماینده
DELIVERED_TO_CUSTOMER تحویل به مشتری
CANCELLED لغو شده

Declared flow (STATUS_FLOW, admin-orders.service.ts:28-35): PLACED → ADMIN_APPROVED → SOURCING → PREPARING → DELIVERED_TO_AGENT → DELIVERED_TO_CUSTOMER. SUSPENDED and CANCELLED are outside the flow.

Transition mechanics (OrdersService.updateStatus, orders.service.ts:111-139):

  • No state-machine validation — any status may be set from any status; STATUS_FLOW is used only to render the timeline (admin-orders.service.ts:249-258).
  • deliveredAt is stamped on DELIVERED_TO_CUSTOMER (orders.service.ts:120).
  • Debt recognition fires at DELIVERED_TO_AGENT, not at DELIVERED_TO_CUSTOMER (orders.service.ts:133-135; mirrors legacy sts>4).
  • Idempotency guard: debt posts only if (newStatus === DELIVERED_TO_AGENT && !order.deliveredAt) (orders.service.ts:133). Note the asymmetry: deliveredAt is set by DELIVERED_TO_CUSTOMER but guards the DELIVERED_TO_AGENT posting — so two successive DELIVERED_TO_AGENT transitions without an intervening DELIVERED_TO_CUSTOMER would both post debt.
  • Every transition writes an OrderStatusEvent with fromStatus/toStatus/note/actor (orders.service.ts:124-126).
  • Status removal (remove: true): refuses DELIVERED_TO_AGENT because the debt is already posted (admin-orders.service.ts:381-386); otherwise deletes the newest matching event and rolls the order back to the latest remaining event's status, defaulting to PLACED (:388-404).
  • Legacy numeric codes 1–6 map onto the enum in statusFromLegacyCode (admin-orders.service.ts:346-356).

Pre-order statuses: PLACED, ADMIN_APPROVED, WORKSHOP_APPROVED, READY_TO_SHIP, DELIVERED, CANCELLED (schema.prisma:80-87); diffs recorded as JSON in PreOrderStatusEvent.changes (schema.prisma:512). Conversion to a real Order is modelled by PreOrder.convertedOrderId (schema.prisma:496-497) but no service method performs the conversion.

Ledger entry "status": the boolean confirmed is the only lifecycle. Order-debt entries are self-confirming (confirmed: true); repayments start pending (confirmed: false) — an intentional deviation from legacy, which auto-confirmed (ledger.service.ts:18-19, 113-146, 213, 65-73).

Comment statuses: PENDING → APPROVED/REJECTED via POST admin/comments/sts with legacy codes 1/2/3 (schema.prisma:655-659, admin-comments.controller.ts:35, admin-comments.service.ts:150).

8.9 Repayments (the real "transaction" path)

LedgerService.postGoldRepayment (ledger.service.ts:156-218) normalizes four instruments into gold grams, then posts a pending GOLD_GRAM CREDIT:

Instrument Formula Required extra sourceType Cite
MELTED_GOLD raw * karatPerMille / 750 karatPerMille (400 if missing) REPAYMENT_MELTED_GOLD :173-180
CASH raw / (pricePerGramRial / 4.331802) pricePerGramRial (400 if missing) REPAYMENT_CASH :182-191
TRANSFER raw (1:1 grams) — REPAYMENT_TRANSFER :193-196
CHEQUE raw (1:1 grams) — REPAYMENT_CHEQUE :197-200

postRialRepayment posts a RIAL CREDIT with sourceType: REPAYMENT_CASH, also pending (ledger.service.ts:225-242) — explicitly noted as not present in legacy (:220-224). postAdjustment posts an ADJUSTMENT in either currency/direction, always confirmed: true, requires note and postedByAdminUserId (ledger.service.ts:244-268) — no caller in src/. postEntry rejects non-positive amounts (amount.lte(0) → 400, ledger.service.ts:34-36). confirmEntry is idempotent (returns early if already confirmed) and then applies to the balance (ledger.service.ts:74-86).

Admin document path (AdminDocumentsService, admin-documents.service.ts):

  • Legacy type codes 1..4 → LedgerSourceType (:26-31); instrument name mapping (:34-39); Farsi labels (:48-57).
  • create picks the raw figure per instrument: CASH→price, MELTED_GOLD→original_value ?? value, else value (:226-231), delegates to postGoldRepayment, then patches instrument metadata (:233-249).
  • toggleConfirm is a bidirectional toggle: confirm delegates to LedgerService.confirmEntry; un-confirm reverses the cached balance in a transaction (CREDIT → +amount, DEBIT → -amount) and clears the flag (:309-338).
  • update refuses to change the amount of a confirmed entry (400, :284-288); allows metadata/note always and amount on unconfirmed entries (:290-294).
  • remove refuses to delete a confirmed entry (400, :347-357).

8.10 Credit limit / suspension (the "limits" logic)

  • Single limit: Customer.goldCreditLimitGrams (legacy gram_level).
  • Check at placement: isSuspendedOverCreditLimit = (totalGoldWeightGrams + customer.goldDebtGrams) > customer.goldCreditLimitGrams (orders.service.ts:71-74).
  • Over limit → the order is created with status SUSPENDED, not rejected; the flag is persisted and a status event notes "Credit limit exceeded" (orders.service.ts:75, 88, 101).
  • The check uses the cached goldDebtGrams, which excludes pending repayments (ledger.service.ts:306-312) — so an unconfirmed repayment does not relax the limit.
  • canPlaceOrders is never enforced in OrdersService.createOrder — it is toggled (admin-customers.service.ts:773-780) and exposed, but no order path reads it.
  • Limit edits: updateCredit (requires gram_level, admin-customers.service.ts:852-864) and update (:612-614), both via new Prisma.Decimal(String(...)).
  • No per-order, daily, or per-customer transaction-count limits exist.

8.11 KYC / verification

  • VerificationStep enum: UNVERIFIED → STEP_ONE_SUBMITTED → STEP_TWO_SUBMITTED → VERIFIED (schema.prisma:54-59).
  • Document URLs on Customer: nationalIdImageUrl, businessLicenseUrl, shopImageUrl (schema.prisma:205-207); persisted on create from national_id_image/business_license/shop_image (admin-customers.service.ts:591-593).
  • AdminCustomersService.verify (admin-customers.service.ts:790-816) takes legacy { customer_id, sts_verify_step_one|sts_verify_step_two } where 2 = accept, anything else = reject:
    • step-one accept → STEP_TWO_SUBMITTED; step-one reject → UNVERIFIED (:801-805)
    • step-two accept → VERIFIED; step-two reject → STEP_ONE_SUBMITTED (:806-810)
    • neither key → 400 (:811-813)
  • Verification is never enforced anywhere — no order, ledger or limit path reads verificationStep. The non_verified list scope only filters the grid (admin-customers.service.ts:170-193).
  • nationalCode is @unique but falls back to the mobile number when not supplied (admin-customers.service.ts:530, 567) — so retail customers systematically carry a national code equal to their phone.

8.12 OTP / SMS

  • No OTP implementation of any kind: no OTP model, no code generation, no verification endpoint, no expiry. (Grep for otp over src/ returns nothing.)
  • SMS is persistence-only — no provider, no HTTP call. AdminMiscService.sendSms validates a non-empty body, resolves the audience and recipient list, and writes an SmsMessage row with sentCount = recipients.length (admin-misc.service.ts:176-195). Nothing is actually transmitted.
  • Audience codes (admin-misc.service.ts:205-221): -1→SINGLE_NUMBER, 0→ALL_CUSTOMERS, 1→SALES_AGENTS, 2→WHOLESALE_CUSTOMERS, 9→MULTIPLE_NUMBERS; plus name aliases single/multiple/all/omde/tak/reagent. RETAIL_CUSTOMERS is reachable only by name — the legacy markup had no numeric option for it (noted :202-203).
  • The customer reactivation SMS legacy sent is deliberately not fired (admin-customers.service.ts:754-757).

8.13 Notifications

  • Notification + NotificationRecipient (per-customer read state), schema.prisma:831-858.
  • createNotification (broadcast by audience) / notifyCustomer (single) / markNotificationRead / findNotification / listNotifications (admin-misc.service.ts:263, 291, 388, 317, 347).
  • Delivery is in-database only — no push, email or SMS dispatch.

8.14 Payment gateway + callbacks

  • None. No gateway module, no redirect/verify endpoints, no callback route, no zarinpal/sadad/IPG reference, no signature verification, and no outbound HTTP client in the dependency list. Money enters the system exclusively as an admin-filed repayment document (§8.9).

8.15 File uploads

  • AdminUploadsService.store (admin-uploads.service.ts:37-69):
    • 400 if no file (:42); max 8 MB MAX_BYTES = 8*1024*1024 (:33, 43-45)
    • MIME allowlist: image/(jpeg|png|gif|webp|svg+xml) or application/pdf (:30, 46-48) — note SVG is accepted, and it is later served inline (public-uploads.controller.ts:23-26), an XSS vector
    • Stored name: md5(Date.now() + originalname + randomUUID()) + ext (:50-54)
    • Written to getUploadDir(), row created with url = '/uploads/<stored>' (:56-68)
  • Path resolution + traversal defence: resolveStoredUpload takes basename, rejects ./.., checks the resolved path stays under the upload dir, and also probes a nested uploads/uploads/ leftover (upload-path.ts:34-48).
  • Serving: static mount at /uploads/ (main.ts:28-36) and a streaming controller at /api/uploads/:filename with a 1-day cache header (public-uploads.controller.ts:17-27), because the user-app's axios base is /api (:9-13).
  • Deliberately not ported: webp re-encoding, watermarking, thumbnailing (dead in legacy too) (admin-uploads.service.ts:20-22).

8.16 Settings

  • One key/value table: Setting { key @id, value Json, updatedAt } (schema.prisma:690-694), replacing legacy config_static.
  • Generic CRUD: getConst / setConst / listConsts / removeConst (admin-misc.service.ts:464-491), exposed at GET|PUT|DELETE admin/misc/const/:key under config.statics.
  • Known keys: wallet.config, wallet.birthday (admin-documents.controller.ts:51,58,64,71), promotion.auto (admin-promotions.service.ts:257,262,275-280), mobile_main_slider (admin-misc.controller.ts:188,193,199).
  • The slider key is privilege-separated: dedicated routes require custom.config.statics.slider, and assertNotSliderKey blocks reaching mobile_main_slider through the generic config.statics routes (admin-misc.controller.ts:184-200, 224-233).
  • setAutoPromotion validates 0 ≤ discount_percent ≤ 100 (admin-promotions.service.ts:261-266).
  • Settings are read with fallbacks, never seeded (§7.2).

8.17 XTable query engine (TableService) — shared by every admin grid

  • Operators → Prisma: =,!=,>,>=,<,<=,like(→contains, % stripped, mode:'insensitive'),in,between(value+value2),null,notnull (table.service.ts:56-91).
  • Field allowlist enforced per call; unknown field → 400 Field not filterable (table.service.ts:49-53) — also applied to sort fields (:121).
  • Dotted paths nested into relation filters (customer.fullName → {customer:{fullName:…}}) (table.service.ts:42-47).
  • Defaults: page=1, limit=10 (table.service.ts:95-96); limit capped at 500 by the DTO (@Max(500)).
  • Sort: '+field' asc / '-field' desc, default {createdAt:'desc'} (table.service.ts:116-124).
  • Response envelope { rows, query:{search,filters,total,limit,page} } (table.service.ts:137-148).
  • Exports are unimplemented and rejected loudly: any op → 400 (table.service.ts:150-157), called by every list method as assertNotExport.

8.18 Branch scoping

AdminOrdersService.list narrows to admin.branchId whenever the caller has one (admin-orders.controller.ts:39, admin-orders.service.ts:75-81), faithfully reproducing legacy. The seed keeps head-office admins branch-less precisely so they are not scoped (seed.ts:140-143, admin-orders.controller.ts:33-37). Same pattern for listSingle (admin-customers.controller.ts:65).


9. Auth

Concern Implementation Cite
Strategy passport-jwt, named 'jwt', token from Authorization: Bearer jwt.strategy.ts:30-37
JWT secret process.env.JWT_SECRET ?? 'dev-only-insecure-secret' — same insecure fallback in two places auth.module.ts:16, jwt.strategy.ts:35
Expiry process.env.JWT_EXPIRES_IN ?? '12h', cast to `${number}h` auth.module.ts:20
ignoreExpiration false (expiry enforced) jwt.strategy.ts:34
Refresh tokens NONE — no refresh endpoint, no refresh token, no rotation, no revocation/blacklist. A token is valid until it expires. (absent)
Token payload { sub: user.id, mobile } only — no roles/permissions baked in auth.service.ts:43, jwt.strategy.ts:7-10
Per-request principal validate() re-reads the AdminUser + userType.permissions on every request, so permission changes take effect immediately jwt.strategy.ts:39-61, comment :49-50
Inactive users !user || !user.isActive → 401 jwt.strategy.ts:45-47
Password hashing bcryptjs, bcrypt.compare on login; cost 10 on customer create and in the seed auth.service.ts:38, admin-customers.service.ts:572, seed.ts:17
Login response { id, name, fullName, mobile, role, ut, branch_id, api_token, roles:[{rid}] } — legacy shape auth.service.ts:45-55
Enumeration defence bad phone and bad password both return 401 (legacy returned 404/422) auth.service.ts:24-26, 34-41
GET admin/auth/me returns raw (unexpanded) rids as permission[]; the frontend runs checkParent() auth.service.ts:58-88, esp. :86

Guards — both registered globally via APP_GUARD in AuthModule (auth.module.ts:29-30):

Guard Behaviour Cite
JwtAuthGuard extends AuthGuard('jwt') Reads IS_PUBLIC_KEY via reflector.getAllAndOverride on handler and class; @Public() → allow; otherwise super.canActivate. Secure-by-default (inverse of legacy's allowlist). guards.ts:35-49, comment :27-34
PermissionsGuard Reads REQUIRED_RID_KEY; no rid → allow. Otherwise requires req.user (else 403) and user.permissions.includes(requiredRid) (else 403 Missing permission: <rid>). guards.ts:52-71

Decorators:

Decorator Key Cite
@Public() SetMetadata('isPublic', true) guards.ts:14-15
@RequirePermission(rid) SetMetadata('requiredRid', rid) guards.ts:18-19
@CurrentAdmin() createParamDecorator → req.user as AuthenticatedAdmin guards.ts:22-25

Permission model:

  • Permissions attach to the UserType (ut), never to an individual admin — every admin of a type shares one set (schema.prisma:600-618, Permission composite PK [rid, userTypeId] :641).
  • expandWithParents(rids) derives ancestors at request time: granting persons.list.client.omde implicitly grants persons.list.client, persons.list, persons (acl.constants.ts:194-203, applied jwt.strategy.ts:60). Port of legacy checkParent().
  • getParents walks up only — a rid never implies its children; the resulting legacy quirk (holding wallet does not grant wallet.list) is deliberately preserved as a documented legacy bug (acl.constants.ts:171-192).
  • setRoles replaces a user type's whole rid set inside one transaction (deleteMany + createMany skipDuplicates) — and performs no validation against ALL_RIDS, so an arbitrary string can be stored as a rid (admin-users.service.ts:317-332).
  • revokeRole removes one rid, 404 if absent (admin-users.service.ts:335-346).
  • AdminRole (the coarse enum) is used for business rules, not for route gating — the only enforcement is SalesAgentService.assertIsSalesExpert (role !== 'SALES_EXPERT' → 403, sales-agent.service.ts:36-40) and queryReagents filtering role: 'SALES_EXPERT' (admin-customers.service.ts:719).

9.1 All role names

AdminRole (5) — schema.prisma:21-27, labels admin-users.service.ts:353-361: SUPER_ADMIN (مدیر ارشد), SYSTEM_APPROVER (تایید مدیر سیستم), SALES_EXPERT (کارشناس فروش), WORKSHOP_SUPERVISOR (سرپرست کارگاه), ACCOUNTANT (حسابدار).

UserType rows seeded (5) — seed.ts:66-98: 85 مدیر ارشد, 86 کارشناس فروش, 87 حسابدار, 88 سرپرست کارگاه, 89 کاربر شعبه.

9.2 All permission names (ALL_RIDS, 144 leaf rids)

Source: src/modules/auth/acl.constants.ts:17-161. Derived as the union of 58 route-gated (meta.role) + 100 in-view ($hasPermission) legacy rids (acl.constants.ts:4-13).

Group Rids
branch branch.create, branch.list
buy buy.order.list
cities cities.create, cities.list, cities.list.remove, cities.list.view, cities.list.view.edit
collection collection.create, collection.list, collection.list.edit, collection.list.remove
comment comment, comment.remove, comment.state, comment.view, comment.view.edit
config config.report, config.statics, config.statics.brand, config.statics.brand.create, config.statics.brand.edit, config.statics.brand.remove, config.statics.tags, config.statics.tags.create, config.statics.tags.edit, config.statics.tags.remove, config.wallet
custom custom.config.statics.slider, custom.config.statics.tags.banner.ids
dashboard dashboard.view
document document.create, document.list, document.list.confirm, document.list.deny, document.list.edit, document.list.get, document.list.remove
invoice invoice.create, invoice.list
newsletters newsletters
notify notify.create, notify.me
offers offers.autopromotion, offers.create, offers.create.tak, offers.list, offers.list.edit, offers.list.remove, offers.list.tak.edit, offers.list.tak.remove, offersIgnore
order order.create, order.list, order.list.pdf, order.list.sts, order.list.sts.remove, order.list.sts.submit, order.list.view, order.list.view.edit, order.makeOrder, order.makeOrder.get, order.makeOrder.remove, order.makeOrder.state, order.makeOrder.state.remove, order.makeOrder.state.submit, order.makeOrder.view, order.makeOrder.view.edit, order.omde, order.omde.get, order.omde.remove, order.omde.state, order.omde.state.remove, order.omde.state.submit, order.omde.view, order.omde.view.edit, order.omde.view.remove, order.tamin, order.tamin.state, order.tamin.state.remove, order.tamin.state.submit, order.tamin.view, order.tamin.view.edit
persons persons.create, persons.create.client, persons.create.customer.tak, persons.create.seller, persons.list.client.omde, persons.list.client.omde.able, persons.list.client.omde.able.order, persons.list.client.omde.edit, persons.list.client.omde.unable, persons.list.client.omde.unable.order, persons.list.client.omde.view, persons.list.client.omde.view.documents, persons.list.client.omde.view.orders, persons.list.client.single, persons.list.client.single.edit, persons.list.client.single.view, persons.list.sellers, persons.list.sellers.able, persons.list.sellers.able.order, persons.list.sellers.edit, persons.list.sellers.unable, persons.list.sellers.unable.order, persons.list.sellers.view, persons.list.sellers.view.documents, persons.list.sellers.view.orders
product product.create, product.list, product.list.edit, product.list.group.edit, product.list.remove, product.list.variants, product.list.variants.add, product.list.variants.edit, product.list.variants.remove, product.list.view, product.percent
report report.order.item, report.product
sms sms.create, sms.create.all, sms.create.multiple, sms.create.omde, sms.create.reagent, sms.create.single, sms.list
statics statics
user user.create, user.list, user.list.create, user.list.edit, user.list.remove, user.permission, user.permission.create, user.permission.edit
wallet wallet, wallet.charge, wallet.list, wallet.percent
workshop workshop.create, workshop.list

Rids declared but never enforced by any route (declared-only; the panel may use them client-side): comment.remove, comment.state, comment.view, comment.view.edit, config.statics.tags* (4), custom.config.statics.tags.banner.ids, document.list.confirm, document.list.deny, document.list.get, document.list.remove, cities.list.view, collection.list.remove, offers.create.tak, offers.list.remove, offers.list.tak.edit, offers.list.tak.remove, order.list.pdf, order.list.sts*, order.list.view*, order.makeOrder.get/remove/state*/view*, order.omde.get/remove/state*/view*, order.tamin.state*/view*, persons.create.client/customer.tak/seller, persons.list.*.able/unable*, persons.list.client.single.edit, persons.list.sellers.edit/able/unable*/view.documents/view.orders, product.list.group.edit/remove/variants*/view, sms.create.all/multiple/omde/reagent/single, user.list.create, wallet.charge/list/percent, buy.order.list, newsletters is enforced, statics is enforced.


10. Jobs / cron / schedulers / websockets

None of any kind. Verified by grep over src/: no @nestjs/schedule, ScheduleModule, @Cron, @Interval, setInterval, node-cron, @WebSocketGateway, SubscribeMessage, OnGatewayInit, or socket.io. Confirmed absent from package.json dependencies too.

The only non-request-triggered work is the boot-time sequence in prepareDatabase() (bootstrap.ts:104-134), executed once before NestFactory.create:

  1. Returns immediately if RUN_MIGRATIONS === 'false' (bootstrap.ts:105).
  2. runMigrations(prisma) applies pending .sql files and logs which were applied (bootstrap.ts:111-117).
  3. Returns if RUN_SEED === 'false' (bootstrap.ts:119).
  4. Resolves the seed script from SEED_SCRIPT / cwd/seed.js / ../../seed.js; warns and skips if none found (bootstrap.ts:12-19, 121-125).
  5. runOnce(prisma, 'seed:initial', ...) spawns the seed as a child process, gated on a marker row so an interrupted seed is retried next boot (bootstrap.ts:127-131, comment :100-102).
  6. Always $disconnect() in finally (bootstrap.ts:132-134).

Consequences worth noting: reconcileBalance (ledger.service.ts:289) is described as "run periodically" but has no scheduler; WalletBonus.expiresAt is never swept; GoldPriceQuote is never refreshed.


11. Env / config keys

No @nestjs/config / ConfigService anywhere — every value is read directly from process.env at module-evaluation or call time, which means JwtModule.register captures JWT_SECRET once at import (auth.module.ts:15-22).

Key Used at Default / fallback Purpose
DATABASE_URL prisma/schema.prisma:14 (env("DATABASE_URL")) none (required) Postgres connection
PORT main.ts:37 3000 HTTP listen port
UPLOAD_DIR main.ts:28, upload-path.ts:13 <cwd>/storage/uploads upload read/write root
JWT_SECRET auth.module.ts:16, jwt.strategy.ts:35 'dev-only-insecure-secret' JWT signing + verification
JWT_EXPIRES_IN auth.module.ts:20 '12h' token lifetime
RUN_MIGRATIONS bootstrap.ts:29 unset → migrations run; 'false' skips boot-time migration switch
RUN_SEED bootstrap.ts:43 unset → seed runs once; 'false' skips boot-time seed switch
SEED_SCRIPT bootstrap.ts:13 falls back to cwd/seed.js, ../../seed.js seed script location
MIGRATIONS_DIR migrate.ts:22 falls back to cwd/migrations, ../../migrations, ../../prisma/migrations, cwd/prisma/migrations migrations location

.env.example declares only 2 of the 9: DATABASE_URL and PORT (.env.example:1-7). PORT must be kept in sync manually with the repo-root API_PORT because Nest/Prisma only load this app's own .env (.env.example:3-6). JWT_SECRET in particular is undocumented there, so a deployment that forgets it silently runs on the hardcoded dev secret.


12. Cross-cutting notes (evidence-backed)

# Observation Cite
1 15 routes are fully unauthenticated, including ledger writes (POST accounting/repayments/*, POST accounting/entries/:id/confirm) and order creation/status changes (POST orders, PATCH orders/:id/status) accounting.controller.ts:7, orders.controller.ts:7
2 orders/dto/update-order-status.dto.ts accepts a client-supplied actorAdminUserId on a @Public() route, so audit actor is attacker-controlled there update-order-status.dto.ts:12-14, orders.controller.ts:22-24
3 JWT_SECRET has an insecure hardcoded fallback duplicated in two files and is absent from .env.example auth.module.ts:16, jwt.strategy.ts:35, .env.example
4 enableCors() with no options → Access-Control-Allow-Origin: * for every route main.ts:25
5 ValidationPipe lacks forbidNonWhitelisted; most admin bodies are Record<string, unknown>, so whitelisting has nothing to strip main.ts:23, e.g. admin-products.controller.ts:103
6 setRoles stores arbitrary strings as rids — no check against ALL_RIDS admin-users.service.ts:317-332
7 Debt idempotency guard for DELIVERED_TO_AGENT tests deliveredAt, which is only set by DELIVERED_TO_CUSTOMER orders.service.ts:120, 133
8 No status-transition validation — any OrderStatus reachable from any other orders.service.ts:111-139
9 canPlaceOrders and verificationStep are toggled/stored but never enforced on any order path admin-customers.service.ts:773-780, 790-816 vs orders.service.ts:32-109
10 reconcileBalance, postAdjustment, PricingService.recordQuote, and LedgerSourceType.WALLET_BONUS/ORDER_PRE_ORDER have no scheduled or in-app producer ledger.service.ts:244-304, pricing.service.ts:9-13, schema.prisma:100,106
11 SVG accepted on upload and streamed back disposition: 'inline' admin-uploads.service.ts:30, public-uploads.controller.ts:23-26
12 nationalCode (unique) silently defaults to the mobile number admin-customers.service.ts:530, 567
13 Two distinct DTO classes share the name UpdateOrderStatusDto with different shapes orders/dto/update-order-status.dto.ts:4, admin-orders/update-status.dto.ts:11
14 Grid exports (op: excel/json/print) are declared in the DTO but always 400 table-query.dto.ts:97-99, table.service.ts:150-157
15 The Setting table is never seeded; all config keys materialize on first write seed.ts (no setting. call), admin-documents.service.ts:416-428
16 Seed ships a dev account whose password equals its phone number (09128081392) with SUPER_ADMIN + all 144 rids seed.ts:23-24, 118-129