From a743f6f93cf6f1c9d427d92f2bfc959c649013a9 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Mon, 21 Sep 2026 17:37:26 +0330 Subject: [PATCH] update admin --- apps/api/src/app.module.ts | 2 + .../modules/customers/customers.service.ts | 9 + .../sales-agent/sales-agent.controller.ts | 43 ++++ .../modules/sales-agent/sales-agent.module.ts | 10 + .../sales-agent/sales-agent.service.ts | 189 ++++++++++++++++++ apps/user-app-v2/src/api/client.ts | 2 + apps/user-app-v2/src/views/user/LoginView.vue | 7 +- apps/user-app/src/api/client.ts | 2 + apps/user-app/src/views/user/LoginView.vue | 7 +- 9 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/modules/sales-agent/sales-agent.controller.ts create mode 100644 apps/api/src/modules/sales-agent/sales-agent.module.ts create mode 100644 apps/api/src/modules/sales-agent/sales-agent.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e800112..87cddaa 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -23,6 +23,7 @@ import { AdminInvoicesModule } from './modules/admin-invoices/admin-invoices.mod import { AdminMiscModule } from './modules/admin-misc/admin-misc.module'; import { AdminUploadsModule } from './modules/admin-uploads/admin-uploads.module'; import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module'; +import { SalesAgentModule } from './modules/sales-agent/sales-agent.module'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard. AdminDashboardModule, CatalogModule, CustomersModule, + SalesAgentModule, OrdersModule, AccountingModule, PricingModule, diff --git a/apps/api/src/modules/customers/customers.service.ts b/apps/api/src/modules/customers/customers.service.ts index a884db1..025677c 100644 --- a/apps/api/src/modules/customers/customers.service.ts +++ b/apps/api/src/modules/customers/customers.service.ts @@ -18,6 +18,7 @@ const PUBLIC_CUSTOMER_SELECT = { businessLicenseUrl: true, shopImageUrl: true, isActive: true, + isArchived: true, createdAt: true, updatedAt: true, } satisfies Prisma.CustomerSelect; @@ -35,8 +36,16 @@ export class CustomersService { return customer; } + // Ported from legacy henza_server's WebApp\AuthController::userAuth() / + // ApiMobileController::userAuth(), both of which query + // Customer::active()->where('is_archive', 0) — an inactive or archived + // customer cannot authenticate in the customer-facing app. This endpoint is + // that same lookup surface for the user-app login flow (client-side match + // on `mobile`), so it must apply the same two filters, or a + // deactivated/archived customer would still be able to log in. list() { return this.prisma.customer.findMany({ + where: { isActive: true, isArchived: false }, select: PUBLIC_CUSTOMER_SELECT, orderBy: { createdAt: 'desc' }, }); diff --git a/apps/api/src/modules/sales-agent/sales-agent.controller.ts b/apps/api/src/modules/sales-agent/sales-agent.controller.ts new file mode 100644 index 0000000..1698f42 --- /dev/null +++ b/apps/api/src/modules/sales-agent/sales-agent.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common'; +import { SalesAgentService } from './sales-agent.service'; +import { CurrentAdmin } from '../auth/guards'; +import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; + +/** + * The sales expert's own routes in the customer apps. + * + * Deliberately NOT part of CustomersModule: CustomersController is @Public() at + * the CLASS level, which would defeat a route-level guard here. This controller + * carries no @Public() at all, so the globally-registered JwtAuthGuard applies to + * every route below and the caller is always a real, token-authenticated + * AdminUser. + * + * Note these are NOT @RequirePermission()-gated. The rids in UserType 86 + * («کارشناس فروش») describe what an agent may do in the ADMIN PANEL; these + * routes are the customer-app surface and are scoped by identity instead — the + * service checks `role === 'SALES_EXPERT'` and every query is filtered to the + * caller's own id, never to an id taken from the request. + */ +@Controller('sales-agent') +export class SalesAgentController { + constructor(private readonly salesAgent: SalesAgentService) {} + + @Get('me') + me(@CurrentAdmin() admin: AuthenticatedAdmin) { + return this.salesAgent.me(admin); + } + + @Get('my-customers') + myCustomers(@CurrentAdmin() admin: AuthenticatedAdmin, @Query('search') search?: string) { + return this.salesAgent.myCustomers(admin, search); + } + + @Post('verify-acting-as') + @HttpCode(200) + verifyActingAs( + @CurrentAdmin() admin: AuthenticatedAdmin, + @Body() body: { customerId: string }, + ) { + return this.salesAgent.verifyActingAs(admin, body.customerId); + } +} diff --git a/apps/api/src/modules/sales-agent/sales-agent.module.ts b/apps/api/src/modules/sales-agent/sales-agent.module.ts new file mode 100644 index 0000000..8fda37e --- /dev/null +++ b/apps/api/src/modules/sales-agent/sales-agent.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SalesAgentService } from './sales-agent.service'; +import { SalesAgentController } from './sales-agent.controller'; + +@Module({ + controllers: [SalesAgentController], + providers: [SalesAgentService], + exports: [SalesAgentService], +}) +export class SalesAgentModule {} diff --git a/apps/api/src/modules/sales-agent/sales-agent.service.ts b/apps/api/src/modules/sales-agent/sales-agent.service.ts new file mode 100644 index 0000000..c4a95a0 --- /dev/null +++ b/apps/api/src/modules/sales-agent/sales-agent.service.ts @@ -0,0 +1,189 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; + +/** + * Backs the sales expert's ("کارشناس فروش") own screens in the customer apps. + * + * Legacy (henza_pwa + henza_server) modelled a sales expert as a `customers` row + * with `type = 1`, so it logged into the customer PWA through the very same + * two-step flow as everyone else and its extras (`POST /my-child`, the + * "فروش گرمی" figure) were just more customer endpoints. The redesigned schema + * models a sales expert as an AdminUser with role SALES_EXPERT instead — it has + * no Customer row, and therefore no balance, ledger or orders of its own + * (`Order.customerId` and `LedgerEntry.customerId` are both NOT NULL and point + * at Customer only). + * + * So the legacy capabilities are rebuilt here against the AdminUser, reading the + * agent's book of business through `Customer.referredByAgentId`. + * + * Eligibility note, carried over from legacy deliberately: legacy's `myChild` + * selected purely on `reagent_id = auth()->id()` and never filtered on `type`. + * The PWA's `type == 1` gate was cosmetic (and bypassable by navigating straight + * to /customerChoice). So "can act on a customer's behalf" means "that customer + * is referred by me", which is what every method here enforces. + */ +@Injectable() +export class SalesAgentService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Only a SALES_EXPERT may use these routes. Every other admin role — an + * accountant, a super admin — gets a clean 403 rather than a half-working + * dashboard, because none of the reads below mean anything for them. + */ + private assertIsSalesExpert(admin: AuthenticatedAdmin) { + if (admin.role !== 'SALES_EXPERT') { + throw new ForbiddenException('این بخش تنها برای کارشناس فروش است.'); + } + } + + /** + * Total gold sold, credited to this agent. + * + * Legacy kept this denormalised in `customers.grams_sell`, recomputed by + * `CustomerController::updateGramSell` as + * SUM(orders.input) WHERE customer_id = X OR reagent_id = X + * with NO order-status filter. Computed live here instead of denormalised. + * + * Two deliberate fidelity notes: + * - The `OR customer_id = X` arm is structurally empty now: an agent is an + * AdminUser and cannot own an Order, so only the referred-customer arm can + * ever contribute. Kept as a comment rather than silently dropped. + * - No status filter, matching legacy. The admin panel disagrees with itself + * here — `AdminCustomersService.listReagents` aggregates unfiltered while + * `reagentDetail` filters on the SOLD statuses — so this matches + * listReagents (the grid figure) and legacy. If those three are ever + * reconciled, this is the fourth call site. + */ + private async gramsSoldFor(agentId: string) { + const agg = await this.prisma.order.aggregate({ + where: { customer: { referredByAgentId: agentId } }, + _sum: { totalGoldWeightGrams: true }, + }); + return agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0); + } + + /** + * The agent's own profile card. Replaces the balance card the customer apps + * show for a real customer, which an agent has no data for. + */ + async me(admin: AuthenticatedAdmin) { + this.assertIsSalesExpert(admin); + + const agent = await this.prisma.adminUser.findUnique({ + where: { id: admin.id }, + select: { + id: true, + fullName: true, + mobile: true, + wagePercent: true, + branch: { select: { id: true, name: true } }, + _count: { select: { referredCustomers: true } }, + }, + }); + if (!agent) throw new NotFoundException('کارشناس فروش یافت نشد'); + + return { + id: agent.id, + fullName: agent.fullName, + mobile: agent.mobile, + wagePercent: agent.wagePercent, + branch: agent.branch, + referredCustomerCount: agent._count.referredCustomers, + gramsSoldGrams: await this.gramsSoldFor(agent.id), + }; + } + + /** + * The agent's referred customers — legacy `POST /my-child`. + * + * Search covers the same columns legacy's myChild did (name, mobile, phone, + * store_name, pasazh_name). + * + * NO isActive filter, deliberately: legacy had `Customer::active()` commented + * out on the line directly above the live query + * (ApiMobileController.php:187-188), and its picker renders a per-row + * activate/deactivate control, which only makes sense when inactive rows are + * listed. Archived customers are excluded though — legacy's own login path + * (WebApp\AuthController::userAuth) treats is_archive as disqualifying. + * + * The select is deliberately narrow. This is customer PII reached through an + * agent's token, so it carries only what the picker renders — no + * nationalCode, no passwordHash. + */ + async myCustomers(admin: AuthenticatedAdmin, search?: string) { + this.assertIsSalesExpert(admin); + + const term = search?.trim(); + const where: Prisma.CustomerWhereInput = { + referredByAgentId: admin.id, + isArchived: false, + ...(term + ? { + OR: [ + { fullName: { contains: term, mode: 'insensitive' as const } }, + { mobile: { contains: term } }, + { phone: { contains: term } }, + { storeName: { contains: term, mode: 'insensitive' as const } }, + { pasazhName: { contains: term, mode: 'insensitive' as const } }, + ], + } + : {}), + }; + + return this.prisma.customer.findMany({ + where, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + fullName: true, + mobile: true, + phone: true, + storeName: true, + address: true, + isActive: true, + goldDebtGrams: true, + fiatDebtRial: true, + }, + }); + } + + /** + * Authorises entering "acting as this customer" mode and records it. + * + * The customer-facing endpoints are @Public() (a pre-existing condition — see + * customers.controller.ts), so this is not a hard security boundary: a + * determined caller could talk to those endpoints directly. What it does buy: + * + * 1. The SERVER decides who may be impersonated, for the legitimate flow — + * the client cannot widen its own book of business. + * 2. An audit row. Without it, staff could place gold-debt-incurring orders + * in a customer's name leaving no trace, which is not an acceptable + * property for a feature that writes to a ledger. Legacy had no such + * record; this is a deliberate improvement on the reference. + */ + async verifyActingAs(admin: AuthenticatedAdmin, customerId: string) { + this.assertIsSalesExpert(admin); + + const customer = await this.prisma.customer.findUnique({ + where: { id: customerId }, + select: { id: true, fullName: true, referredByAgentId: true, isArchived: true }, + }); + + if (!customer || customer.isArchived || customer.referredByAgentId !== admin.id) { + throw new ForbiddenException('این مشتری در فهرست شما نیست.'); + } + + await this.prisma.customerActivityLog.create({ + data: { + customerId: customer.id, + action: 'agent.acting_as', + detail: `کارشناس فروش ${admin.fullName} (${admin.id}) به‌جای این مشتری وارد شد`, + }, + }); + + return { id: customer.id, fullName: customer.fullName }; + } +} diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts index d75f0aa..3d0e633 100644 --- a/apps/user-app-v2/src/api/client.ts +++ b/apps/user-app-v2/src/api/client.ts @@ -77,6 +77,8 @@ export interface CustomerSummary { goldDebtGrams: string; fiatDebtRial: string; goldCreditLimitGrams: string; + isActive: boolean; + isArchived: boolean; } export interface OrderSummary { diff --git a/apps/user-app-v2/src/views/user/LoginView.vue b/apps/user-app-v2/src/views/user/LoginView.vue index 8a9de79..50bcd83 100644 --- a/apps/user-app-v2/src/views/user/LoginView.vue +++ b/apps/user-app-v2/src/views/user/LoginView.vue @@ -124,8 +124,13 @@ const countDownTimer = () => { const sendPhone = async () => { try { body.loading = true + // GET /api/customers already filters to isActive && !isArchived (matching + // legacy henza_server's Customer::active()->where('is_archive', 0)), but + // the check is repeated here in case that ever changes upstream — an + // inactive/archived customer must never be able to log in. const customers = await api.listCustomers() - matchedCustomer = customers.find((c) => c.mobile === formModel.username) ?? null + matchedCustomer = + customers.find((c) => c.mobile === formModel.username && c.isActive && !c.isArchived) ?? null if (!matchedCustomer) { notify.text = 'اطلاعات کاربری در سیستم وجود ندارد.' notify.show = true diff --git a/apps/user-app/src/api/client.ts b/apps/user-app/src/api/client.ts index c907eba..443c641 100644 --- a/apps/user-app/src/api/client.ts +++ b/apps/user-app/src/api/client.ts @@ -92,6 +92,8 @@ export interface CustomerSummary { goldDebtGrams: string; fiatDebtRial: string; goldCreditLimitGrams: string; + isActive: boolean; + isArchived: boolean; } export interface OrderSummary { diff --git a/apps/user-app/src/views/user/LoginView.vue b/apps/user-app/src/views/user/LoginView.vue index 8a9de79..50bcd83 100644 --- a/apps/user-app/src/views/user/LoginView.vue +++ b/apps/user-app/src/views/user/LoginView.vue @@ -124,8 +124,13 @@ const countDownTimer = () => { const sendPhone = async () => { try { body.loading = true + // GET /api/customers already filters to isActive && !isArchived (matching + // legacy henza_server's Customer::active()->where('is_archive', 0)), but + // the check is repeated here in case that ever changes upstream — an + // inactive/archived customer must never be able to log in. const customers = await api.listCustomers() - matchedCustomer = customers.find((c) => c.mobile === formModel.username) ?? null + matchedCustomer = + customers.find((c) => c.mobile === formModel.username && c.isActive && !c.isArchived) ?? null if (!matchedCustomer) { notify.text = 'اطلاعات کاربری در سیستم وجود ندارد.' notify.show = true