update admin
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,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
|
||||
|
||||
Reference in New Issue
Block a user