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>
95 KiB
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 aGoldPriceQuote(pricing.service.ts:9-13). No caller anywhere insrc/— 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)— allPrisma.Decimalarithmetic (.sub,.div,.add,.mul), no float (orders.service.ts:39-43). rashWeightis 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 toOrdersService.createOrderrather 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 tovariant.defaultWagePercentwhen 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 viaGET admin/misc/cities/percent/:idandpercent-aux/:id(admin-misc.controller.ts:40-50) but never used in pricing. - Bulk wage change:
AdminProductsService.changePercentsetsdefaultWagePercentacross a brand/category filter viaupdateMany, usingnew Prisma.Decimal(String(percent))(admin-products.service.ts:641-657);changePercentCountpreviews the affected count (:630-639). - Tax / VAT: no tax logic anywhere.
- Spread / buy-vs-sell price: none — a single
GoldPriceQuote.pricePerGramRialper karat, no bid/ask.
8.4 Rounding & precision (Decimal vs float) — exact findings
- Schema: zero
Floatfields; every monetary/weight/percent column isDecimalwith 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):
ledger.service.ts:189— CASH conversion divides byGRAMS_PER_MESGHAL, a JS number literal4.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).admin-dashboard.service.ts:148and:347— sorting aggregates withNumber(b.weight) - Number(a.weight), i.e. Decimal→float for ordering only (display, not stored).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,toFixedorDecimal.toDecimalPlacesinsrc/. All quantization is implicit at the DB column scale. - Karat normalization:
goldGrams = raw * karatPerMille / 750, withSTANDARD_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/fiatDebtRialare 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).getBalancereads the cache + credit limit (ledger.service.ts:270-282).reconcileBalancerecomputes from confirmed entries only:goldDebits - goldCredits,rialDebits - rialCredits, and overwrites the cache (ledger.service.ts:284-304);sumConfirmedusesaggregate._sumfiltered onconfirmed: 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 viaPOST /api/accounting/customers/:customerId/reconcile, which is@Public().- Wallet bonus is a separate concept from debt:
WalletBonusin gold grams with a mandatoryexpiresAt.chargeWalletvalidatesgrams > 0and 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 touchesgoldDebtGramsdespiteLedgerSourceType.WALLET_BONUSexisting (schema.prisma:106) — that enum value has no producer insrc/. - 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/SELLside, no order matching, no quote-locking. - Order placement:
OrdersService.createOrder(orders.service.ts:32-109); admin builder pathAdminOrdersService.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@uniquecolumn. - 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_FLOWis used only to render the timeline (admin-orders.service.ts:249-258). deliveredAtis 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:deliveredAtis 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
OrderStatusEventwithfromStatus/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).
createpicks the raw figure per instrument: CASH→price, MELTED_GOLD→original_value ?? value, elsevalue(:226-231), delegates topostGoldRepayment, then patches instrument metadata (:233-249).toggleConfirmis a bidirectional toggle: confirm delegates toLedgerService.confirmEntry; un-confirm reverses the cached balance in a transaction (CREDIT → +amount,DEBIT → -amount) and clears the flag (:309-338).updaterefuses to change the amount of a confirmed entry (400, :284-288); allows metadata/note always and amount on unconfirmed entries (:290-294).removerefuses to delete a confirmed entry (400, :347-357).
8.10 Credit limit / suspension (the "limits" logic)
- Single limit:
Customer.goldCreditLimitGrams(legacygram_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. canPlaceOrdersis never enforced inOrdersService.createOrder— it is toggled (admin-customers.service.ts:773-780) and exposed, but no order path reads it.- Limit edits:
updateCredit(requiresgram_level, admin-customers.service.ts:852-864) andupdate(:612-614), both vianew Prisma.Decimal(String(...)). - No per-order, daily, or per-customer transaction-count limits exist.
8.11 KYC / verification
VerificationStepenum: 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 fromnational_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)
- step-one accept →
- Verification is never enforced anywhere — no order, ledger or limit path reads
verificationStep. Thenon_verifiedlist scope only filters the grid (admin-customers.service.ts:170-193). nationalCodeis@uniquebut 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
otpoversrc/returns nothing.) - SMS is persistence-only — no provider, no HTTP call.
AdminMiscService.sendSmsvalidates a non-empty body, resolves the audience and recipient list, and writes anSmsMessagerow withsentCount = 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_CUSTOMERSis 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/IPGreference, 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)orapplication/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 withurl = '/uploads/<stored>'(:56-68)
- 400 if no file (:42); max 8 MB
- Path resolution + traversal defence:
resolveStoredUploadtakesbasename, rejects./.., checks the resolved path stays under the upload dir, and also probes a nesteduploads/uploads/leftover (upload-path.ts:34-48). - Serving: static mount at
/uploads/(main.ts:28-36) and a streaming controller at/api/uploads/:filenamewith 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 legacyconfig_static. - Generic CRUD:
getConst/setConst/listConsts/removeConst(admin-misc.service.ts:464-491), exposed atGET|PUT|DELETE admin/misc/const/:keyunderconfig.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, andassertNotSliderKeyblocks reachingmobile_main_sliderthrough the genericconfig.staticsroutes (admin-misc.controller.ts:184-200, 224-233). setAutoPromotionvalidates0 ≤ 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);limitcapped 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 asassertNotExport.
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: grantingpersons.list.client.omdeimplicitly grantspersons.list.client,persons.list,persons(acl.constants.ts:194-203, applied jwt.strategy.ts:60). Port of legacycheckParent().getParentswalks up only — a rid never implies its children; the resulting legacy quirk (holdingwalletdoes not grantwallet.list) is deliberately preserved as a documented legacy bug (acl.constants.ts:171-192).setRolesreplaces a user type's whole rid set inside one transaction (deleteMany + createMany skipDuplicates) — and performs no validation againstALL_RIDS, so an arbitrary string can be stored as a rid (admin-users.service.ts:317-332).revokeRoleremoves 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 isSalesAgentService.assertIsSalesExpert(role !== 'SALES_EXPERT'→ 403, sales-agent.service.ts:36-40) andqueryReagentsfilteringrole: '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:
- Returns immediately if
RUN_MIGRATIONS === 'false'(bootstrap.ts:105). runMigrations(prisma)applies pending.sqlfiles and logs which were applied (bootstrap.ts:111-117).- Returns if
RUN_SEED === 'false'(bootstrap.ts:119). - Resolves the seed script from
SEED_SCRIPT/cwd/seed.js/../../seed.js; warns and skips if none found (bootstrap.ts:12-19, 121-125). 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).- Always
$disconnect()infinally(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 |