update admin

This commit is contained in:
s.esmaeili
2026-09-21 17:37:26 +03:30
parent ef1d4638cc
commit a743f6f93c
9 changed files with 269 additions and 2 deletions
+2
View File
@@ -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,
@@ -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' },
});
@@ -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);
}
}
@@ -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 {}
@@ -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 };
}
}
+2
View File
@@ -77,6 +77,8 @@ export interface CustomerSummary {
goldDebtGrams: string;
fiatDebtRial: string;
goldCreditLimitGrams: string;
isActive: boolean;
isArchived: boolean;
}
export interface OrderSummary {
@@ -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
+2
View File
@@ -92,6 +92,8 @@ export interface CustomerSummary {
goldDebtGrams: string;
fiatDebtRial: string;
goldCreditLimitGrams: string;
isActive: boolean;
isArchived: boolean;
}
export interface OrderSummary {
+6 -1
View File
@@ -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