feat(api): sales-expert login to the customer apps (D21)

A sales expert can now sign into the apps with their AdminUser account, without
reversing D17 — the expert remains an AdminUser and is never turned back into a
Customer.

The app token carries a `typ` of 'customer' or 'sales-expert'; the strategy
resolves both to one principal whose `id` is always a Customer id, so every
route already written for a customer works unchanged for an expert. That is what
the reference gave them, where an agent was a customer with extra screens.

Each expert gets ONE linked trading Customer row (additive migration, dry-run
clean and rolled back before applying) created at first sign-in, seeded from the
two numbers the reference read off the agent's own row: SalesExpertProfile
.gramLevel -> goldCreditLimitGrams and AdminUser.wagePercent ->
makingChargeOffsetPercent. Orders the expert places land there, exactly as the
reference's single row did.

New expert-only routes scope on the AdminUser id from the token:
POST expert/child-orders (legacy /order/child, ApiMobileController:759, keeping
its sts=5 filter as DELIVERED_TO_AGENT), POST expert/my-customers (legacy
/my-child, :184) and GET expert/whoami so the apps can hide the entries.

Security: jwt.strategy now rejects any token carrying an app `typ`. An expert's
app token holds their AdminUser id in `sub`, and JWT_CUSTOMER_SECRET falls back
to JWT_SECRET when unset — in that configuration the token would otherwise have
validated as an admin with full panel permissions.

NOT ported: myChild minted an api_token per listed customer (:197-201), letting
an agent act AS that customer. Recorded in D21 rather than silently dropped.

Tests: new test-sales-expert-app-login.cjs 36/36 — expert sign-in, trading
account seeding, own-customers-only (with both negatives), child orders, the
expert ordering for themselves, an ordinary customer refused both screens with
their own app unaffected, and all four token boundaries including immediate
lockout on deactivation. jest 45/45, admin smoke 43/43, apps smoke 43/43,
B2B scan 34/34, B2B writes 25/25, auth-smoke 32/32.

[ref: henza_server/app/Http/Controllers/Api/ApiMobileController.php:184-195,534-535,759-781]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 01:05:36 +03:30
co-authored by Claude Opus 5
parent 53436ec855
commit 69f4ba2b29
13 changed files with 1124 additions and 32 deletions
+22
View File
@@ -7,8 +7,25 @@ import { expandWithParents } from './acl.constants';
export interface JwtPayload {
sub: string;
mobile: string;
/**
* Set on APP tokens only ('customer' or 'sales-expert'). An admin-panel
* token has no `typ`. Checked in validate() below — see the note there.
*/
typ?: string;
}
/**
* App token types, which must NEVER authenticate an admin-panel route.
*
* This matters because a sales expert's app token carries their AdminUser id
* in `sub` (D21). The two token families are signed with different secrets, so
* normally the signature alone stops it — but `JWT_CUSTOMER_SECRET` falls back
* to `JWT_SECRET` when unset, and in that configuration an expert's app token
* WOULD verify here and be handed full admin-panel permissions. This check
* closes that regardless of how the secrets are configured.
*/
const APP_TOKEN_TYPES = new Set(['customer', 'sales-expert']);
/**
* The authenticated principal attached to `req.user`.
*
@@ -37,6 +54,11 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
async validate(payload: JwtPayload): Promise<AuthenticatedAdmin> {
// An app token is never an admin-panel token, whatever it is signed with.
if (payload?.typ && APP_TOKEN_TYPES.has(payload.typ)) {
throw new UnauthorizedException();
}
const user = await this.prisma.adminUser.findUnique({
where: { id: payload.sub },
include: { userType: { include: { permissions: true } } },
@@ -0,0 +1,62 @@
/**
* Who is signed into the customer apps.
*
* The apps serve TWO kinds of user — an ordinary customer, and a sales expert
* ("کارشناس فروش"). In the reference both were rows in `customers` (the agent
* was `type = 1`), so one login and one token covered both and the agent simply
* had extra screens. Here a sales expert is an AdminUser (D17), so the two
* principals are genuinely different records and need to be told apart.
*
* The distinction is carried in the token's `typ` claim and re-checked on every
* request, never taken from the client.
*/
/** An ordinary customer signed into the app. */
export const CUSTOMER_TOKEN_TYPE = 'customer' as const;
/**
* A sales expert signed into the app. A DIFFERENT value from the admin panel's
* token type, so an expert's app token is not an admin-panel token: it cannot
* open the admin panel, and an admin token cannot drive the app.
*/
export const EXPERT_TOKEN_TYPE = 'sales-expert' as const;
export type AppTokenType = typeof CUSTOMER_TOKEN_TYPE | typeof EXPERT_TOKEN_TYPE;
export interface AppJwtPayload {
/**
* For a customer: the Customer id.
* For an expert: the ADMIN USER id — the durable identity. Their trading
* Customer row is resolved server-side on each request, so the token never
* needs reissuing when that row is created.
*/
sub: string;
mobile: string;
typ: AppTokenType;
}
/**
* The principal a handler receives from `@CurrentCustomer()`.
*
* `id` is ALWAYS a Customer id — for an expert it is their trading account —
* so every existing customer route (orders, cart, me, ledger) keeps working
* unchanged and an expert gets the whole ordinary app for free, which is what
* the reference gave them.
*
* `adminUserId` is set only for an expert and is what scopes "my customers":
* Customer.referredByAgentId points at the AdminUser, not at the trading row.
*/
export interface AuthenticatedCustomer {
/** Customer id. For an expert, their own trading account. */
id: string;
mobile: string;
/** Which kind of principal this is. */
type: AppTokenType;
/** The AdminUser id — present only for a sales expert. */
adminUserId?: string;
}
/** True when this principal is a sales expert. */
export function isSalesExpert(me: AuthenticatedCustomer): boolean {
return me.type === EXPERT_TOKEN_TYPE && Boolean(me.adminUserId);
}
@@ -1,9 +1,9 @@
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { MessageAudience } from '@prisma/client';
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { CUSTOMER_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits';
import { SmsService, smsConfigured } from '../sms/sms.service';
@@ -71,12 +71,24 @@ export class CustomerAuthService {
await this.assertSendAllowed(mobile, ip);
// D21 — a sales expert signs in with the SAME flow, on the mobile of their
// AdminUser. Checked first: the owner ruled that if a number somehow
// existed on both sides, the expert wins.
//
// Their trading Customer row is created here rather than at verify, because
// CustomerOtp.customerId is a required FK and the challenge has to hang off
// a real row. Creating it costs nothing for a number that never completes
// login: it is that expert's own account either way, and it is created once.
const expertAccount = await this.resolveExpertTradingAccount(mobile);
// Legacy used Customer::active(), and the archived filter matches the
// customer-list lookup the app login previously relied on.
const customer = await this.prisma.customer.findFirst({
where: { mobile, isActive: true, isArchived: false },
select: { id: true },
});
const customer =
expertAccount ??
(await this.prisma.customer.findFirst({
where: { mobile, isActive: true, isArchived: false },
select: { id: true },
}));
// Deliberately opaque: the reference used firstOrFail() and leaked a 404
// for unknown numbers, which let anyone enumerate registered customers.
@@ -102,6 +114,117 @@ export class CustomerAuthService {
return { sent: true };
}
/**
* D21 — find an active sales expert by mobile and return their trading
* Customer account, creating it on first sign-in.
*
* WHY A TRADING ACCOUNT EXISTS
* In the reference a sales agent WAS a Customer (type=1), so `setOrder` did
* `Customer::findOrFail(auth()->id())` and read that row's `gram_level`
* (ApiMobileController.php:534-535) — the agent's own order, own credit
* limit, own ledger. Here the expert is an AdminUser (D17), which has no
* ledger and cannot be an Order's subject, so this one linked Customer row
* plays that part. It is NOT one of their referred customers.
*
* Seeded from what the reference used for exactly these two numbers:
* gram_level -> goldCreditLimitGrams (SalesExpertProfile.gramLevel)
* percent -> makingChargeOffsetPercent (AdminUser.wagePercent)
*
* Returns null for any mobile that is not an active sales expert, so an
* ordinary customer's login is completely unaffected.
*/
private async resolveExpertTradingAccount(mobile: string): Promise<{ id: string } | null> {
const admin = await this.prisma.adminUser.findFirst({
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
select: {
id: true,
fullName: true,
firstName: true,
lastName: true,
mobile: true,
wagePercent: true,
salesExpertProfile: {
select: { id: true, gramLevel: true, nationalCode: true, tradingCustomerId: true },
},
},
});
if (!admin) return null;
const existingId = admin.salesExpertProfile?.tradingCustomerId;
if (existingId) {
// Re-activate nothing and change nothing — just confirm it is usable.
const existing = await this.prisma.customer.findUnique({
where: { id: existingId },
select: { id: true, isActive: true, isArchived: true },
});
if (existing && existing.isActive && !existing.isArchived) return { id: existing.id };
// Deliberately does NOT silently create a second account when the first
// was deactivated: an operator disabled it, and that decision stands.
this.logger.warn(
`Sales expert ${admin.id} has a trading account that is inactive or archived; refusing to create another.`,
);
return null;
}
if (!admin.salesExpertProfile) {
// Every SALES_EXPERT gets a profile on create and on role change (D17),
// so this means something bypassed both paths.
this.logger.warn(`Sales expert ${admin.id} has no SalesExpertProfile; cannot sign in to the app.`);
return null;
}
// The mobile is unique on Customer, so a pre-existing customer row with
// this number would collide. That cannot normally happen — the owner
// stated expert and customer numbers are never the same — but if it does,
// adopt that row rather than fail the login or create a duplicate.
const clash = await this.prisma.customer.findFirst({
where: { mobile },
select: { id: true, isActive: true, isArchived: true },
});
if (clash) {
this.logger.warn(
`Mobile ${mobile} belongs to both sales expert ${admin.id} and customer ${clash.id}; ` +
`adopting the existing customer row as the expert's trading account.`,
);
if (!clash.isActive || clash.isArchived) return null;
await this.prisma.salesExpertProfile.update({
where: { id: admin.salesExpertProfile.id },
data: { tradingCustomerId: clash.id },
});
return { id: clash.id };
}
const created = await this.prisma.customer.create({
data: {
fullName: admin.fullName,
firstName: admin.firstName,
lastName: admin.lastName,
mobile: admin.mobile,
// Customer.nationalCode is required and unique. The expert's own code
// when they have one, else the mobile — the same fallback
// admin-customers.service.ts uses when a form omits it.
nationalCode: admin.salesExpertProfile.nationalCode || admin.mobile,
// No app password: this account is reached only through the expert's
// AdminUser session, never by a password login of its own.
passwordHash: '',
kind: CustomerKind.APP_SHOPKEEPER,
goldCreditLimitGrams: admin.salesExpertProfile.gramLevel,
makingChargeOffsetPercent: admin.wagePercent ?? new Prisma.Decimal(0),
},
select: { id: true },
});
await this.prisma.salesExpertProfile.update({
where: { id: admin.salesExpertProfile.id },
data: { tradingCustomerId: created.id },
});
this.logger.log(
`Created trading account ${created.id} for sales expert ${admin.id} on first app sign-in.`,
);
return { id: created.id };
}
/**
* Step 2 — port of ApiMobileController::userVerfiy (henza_server:51-64).
*
@@ -131,6 +254,19 @@ export class CustomerAuthService {
// and DELIVERY.md.
const master = masterOtpCode();
if (master !== null && code === master) {
// D21 — the master code works for a sales expert too, and the expert is
// checked first for the same reason requestOtp does it.
const expert = await this.resolveExpertPrincipal(mobile);
if (expert) {
this.logger.warn(
`OTP_MASTER_CODE used to sign in SALES EXPERT ${expert.adminUserId} (mobile ${mobile})`,
);
return {
token: await this.issueExpertToken(expert.adminUserId, mobile),
customer: { id: expert.tradingCustomerId, mobile },
};
}
const customer = await this.prisma.customer.findFirst({
where: { mobile, isActive: true, isArchived: false },
select: { id: true, mobile: true },
@@ -187,12 +323,73 @@ export class CustomerAuthService {
data: { consumedAt: new Date() },
});
// D21 — the challenge hangs off the expert's trading Customer row, so a
// correct code here may belong to either principal. Resolved from the
// mobile, never from anything the client sent.
const expert = await this.resolveExpertPrincipal(mobile);
if (expert) {
return {
token: await this.issueExpertToken(expert.adminUserId, mobile),
customer: { id: expert.tradingCustomerId, mobile },
};
}
return {
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile),
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
};
}
/**
* D21 — is this mobile an active sales expert with a usable trading account?
*
* Read-only: unlike `resolveExpertTradingAccount`, this never creates
* anything, because by verify time requestOtp has already done so. Returns
* null for every ordinary customer, so their path is untouched.
*/
private async resolveExpertPrincipal(
mobile: string,
): Promise<{ adminUserId: string; tradingCustomerId: string } | null> {
const admin = await this.prisma.adminUser.findFirst({
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
select: {
id: true,
salesExpertProfile: {
select: {
tradingCustomer: { select: { id: true, isActive: true, isArchived: true } },
},
},
},
});
const trading = admin?.salesExpertProfile?.tradingCustomer;
if (!admin || !trading || !trading.isActive || trading.isArchived) return null;
return { adminUserId: admin.id, tradingCustomerId: trading.id };
}
/**
* Sign a SALES EXPERT app token.
*
* `sub` is the AdminUser id — the durable identity — not the trading
* Customer id, so the token survives anything that changes the trading row
* and the role check can be re-applied on every request.
*
* Same secret and lifetime as a customer token; only `typ` differs. It is
* NOT an admin-panel token: JwtStrategy requires the admin type and refuses
* this one on every admin route.
*/
private issueExpertToken(adminUserId: string, mobile: string): Promise<string> {
const payload: CustomerJwtPayload = {
sub: adminUserId,
mobile,
typ: EXPERT_TOKEN_TYPE,
};
return this.jwt.signAsync(payload, {
secret: customerJwtSecret(),
expiresIn: (process.env.JWT_CUSTOMER_EXPIRES_IN ?? '30d') as `${number}d`,
});
}
/**
* Sign a customer JWT. Shared by the normal verify path and the D20 master
* code so both issue exactly the same token — the bypass grants no extra
@@ -1,32 +1,43 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { AdminRole } from '@prisma/client';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
import { customerJwtSecret } from './customer-auth.service';
import {
CUSTOMER_TOKEN_TYPE,
EXPERT_TOKEN_TYPE,
type AppJwtPayload,
type AuthenticatedCustomer,
} from './app-principal';
export {
CUSTOMER_TOKEN_TYPE,
EXPERT_TOKEN_TYPE,
type AuthenticatedCustomer,
} from './app-principal';
/** Kept for the existing imports; the payload now covers both principals. */
export type CustomerJwtPayload = AppJwtPayload;
/**
* Marks a token as a customer token. Checked on every customer route, so an
* admin token — even one signed with the same secret by misconfiguration —
* cannot be used to read customer data (T1).
*/
export const CUSTOMER_TOKEN_TYPE = 'customer' as const;
export interface CustomerJwtPayload {
sub: string;
mobile: string;
typ: typeof CUSTOMER_TOKEN_TYPE;
}
/** What `@CurrentCustomer()` hands a handler. */
export interface AuthenticatedCustomer {
id: string;
mobile: string;
}
/**
* The customer principal, kept entirely separate from JwtStrategy (which reads
* AdminUser). The reference had the same split: `auth:api` for admins and
* `auth('api-customer')` for the app.
* The app principal, kept entirely separate from JwtStrategy (which reads
* AdminUser for the admin PANEL). The reference had the same split: `auth:api`
* for admins and `auth('api-customer')` for the app.
*
* This strategy accepts TWO token types and resolves both to one shape:
*
* typ 'customer' -> the Customer row named by `sub`
* typ 'sales-expert' -> the AdminUser named by `sub`, plus their trading
* Customer row, which becomes `id`
*
* Because `id` is always a Customer id, every route already written for a
* customer works unchanged for an expert — which is exactly the reference's
* behaviour, where an agent was a customer with extra screens.
*
* An expert's app token is NOT an admin-panel token: its `typ` is
* 'sales-expert', while JwtStrategy requires the admin type, so it is refused
* on every admin route.
*/
@Injectable()
export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jwt') {
@@ -38,9 +49,13 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
});
}
async validate(payload: CustomerJwtPayload): Promise<AuthenticatedCustomer> {
// A token that is not explicitly a customer token is refused, whatever else
// it carries.
async validate(payload: AppJwtPayload): Promise<AuthenticatedCustomer> {
if (payload?.typ === EXPERT_TOKEN_TYPE) {
return this.validateExpert(payload);
}
// A token that is not explicitly a customer token is refused, whatever
// else it carries.
if (payload?.typ !== CUSTOMER_TOKEN_TYPE) {
throw new UnauthorizedException();
}
@@ -56,6 +71,51 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
throw new UnauthorizedException();
}
return { id: customer.id, mobile: customer.mobile };
return { id: customer.id, mobile: customer.mobile, type: CUSTOMER_TOKEN_TYPE };
}
/**
* A sales expert. `sub` is their AdminUser id, so the role and active flag
* are re-checked on every request: demoting an expert or deactivating them
* locks them out immediately rather than at token expiry.
*
* The trading Customer row is resolved here rather than stored in the token,
* so a token issued before that row existed keeps working.
*/
private async validateExpert(payload: AppJwtPayload): Promise<AuthenticatedCustomer> {
const admin = await this.prisma.adminUser.findUnique({
where: { id: payload.sub },
select: {
id: true,
mobile: true,
role: true,
isActive: true,
salesExpertProfile: {
select: {
tradingCustomerId: true,
tradingCustomer: { select: { id: true, isActive: true, isArchived: true } },
},
},
},
});
if (!admin || !admin.isActive || admin.role !== AdminRole.SALES_EXPERT) {
throw new UnauthorizedException();
}
const trading = admin.salesExpertProfile?.tradingCustomer;
if (!trading || !trading.isActive || trading.isArchived) {
// The account is created at sign-in; if it is missing or has been
// deactivated, the session cannot act. Refused rather than silently
// degraded, so an expert never places an order against nothing.
throw new UnauthorizedException();
}
return {
id: trading.id,
mobile: admin.mobile,
type: EXPERT_TOKEN_TYPE,
adminUserId: admin.id,
};
}
}
@@ -31,6 +31,10 @@ describe('OTP_MASTER_CODE (D20)', () => {
let prisma: {
customer: { findFirst: jest.Mock };
customerOtp: { findFirst: jest.Mock; update: jest.Mock; create: jest.Mock; count: jest.Mock };
// D21 — verify now asks whether the mobile is a sales expert before
// treating it as a customer. These tests are all about the CUSTOMER path,
// so it answers "not an expert" and the customer branch runs as before.
adminUser: { findFirst: jest.Mock };
};
let jwt: { signAsync: jest.Mock };
let sms: { send: jest.Mock };
@@ -48,6 +52,8 @@ describe('OTP_MASTER_CODE (D20)', () => {
create: jest.fn().mockResolvedValue({}),
count: jest.fn().mockResolvedValue(0),
},
// No sales expert on this mobile — the customer path under test.
adminUser: { findFirst: jest.fn().mockResolvedValue(null) },
};
jwt = { signAsync: jest.fn().mockResolvedValue('signed.jwt.token') };
sms = { send: jest.fn().mockResolvedValue({ ok: true }) };
@@ -0,0 +1,66 @@
import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common';
import { SalesExpertAppService } from './sales-expert-app.service';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/app-principal';
/**
* The sales expert's own screens in the customer apps.
*
* POST expert/child-orders -> legacy POST /order/child (ApiMobileController:759)
* POST expert/my-customers -> legacy POST /my-child (ApiMobileController:184)
* GET expert/me -> the expert's profile strip
*
* Every route sits behind the app token and takes its scope from
* `me.adminUserId`, which only a sales-expert token carries. An ordinary
* customer's token has it undefined, so the service refuses with 403 — the
* apps also hide the menu entries, but the server does not rely on that.
*
* No route accepts a customer id or an agent id from the client.
*/
@Controller('expert')
export class SalesExpertAppController {
constructor(private readonly expert: SalesExpertAppService) {}
/** Legacy POST /order/child — POST because the reference posted its paging. */
@CustomerAuth()
@Post('child-orders')
@HttpCode(200)
childOrders(
@Body() body: { page?: number; limit?: number; search?: string | null },
@CurrentCustomer() me: AuthenticatedCustomer,
) {
return this.expert.childOrders(me.adminUserId, body ?? {});
}
/** Legacy POST /my-child. */
@CustomerAuth()
@Post('my-customers')
@HttpCode(200)
myCustomers(
@Body() body: { search?: string | null; text_search?: string | null },
@CurrentCustomer() me: AuthenticatedCustomer,
) {
// The reference read `text_search` (ApiMobileController.php:186); the
// ported screens send `search`. Both accepted.
return this.expert.myCustomers(me.adminUserId, {
search: body?.search ?? body?.text_search ?? null,
});
}
@CustomerAuth()
@Get('me')
me(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.expert.me(me.adminUserId);
}
/**
* Whether the signed-in principal is a sales expert. The apps call this once
* after login to decide whether to show the two expert entries; the answer
* comes from the token, so a customer cannot make it say yes.
*/
@CustomerAuth()
@Get('whoami')
whoami(@CurrentCustomer() me: AuthenticatedCustomer) {
return { isSalesExpert: Boolean(me.adminUserId), type: me.type };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { SalesExpertAppController } from './sales-expert-app.controller';
import { SalesExpertAppService } from './sales-expert-app.service';
/** The sales expert's own screens in the customer apps (D21). */
@Module({
imports: [PrismaModule],
controllers: [SalesExpertAppController],
providers: [SalesExpertAppService],
exports: [SalesExpertAppService],
})
export class SalesExpertAppModule {}
@@ -0,0 +1,216 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
/**
* The two screens a sales expert has in the app beyond what every customer
* gets: "سفارش مشتریان" (their referred customers' orders) and
* "انتخاب مشتری" (their referred customers).
*
* Reference: ApiMobileController::getChildOrder (:759-781) and ::myChild
* (:184-195). Both scoped on `reagent_id = auth()->id()` — in the reference the
* agent was a Customer, so that was the logged-in customer's own id. Here the
* expert is an AdminUser (D17) and `Customer.referredByAgentId` points at it,
* so the scope is the AdminUser id taken from the token. Nothing is ever read
* from the request.
*/
@Injectable()
export class SalesExpertAppService {
constructor(private readonly prisma: PrismaService) {}
/** Refuses any principal that is not a sales expert. */
private assertExpert(adminUserId: string | undefined): string {
if (!adminUserId) {
throw new ForbiddenException('این بخش فقط برای کارشناس فروش است');
}
return adminUserId;
}
/**
* Port of `getChildOrder` (ApiMobileController.php:759-781).
*
* The reference filtered `where('sts', 5)` — legacy status 5 is
* «تحویل مسئول فروش» (delivered to the sales agent), the point at which the
* agent has to act on the order. Reproduced with the equivalent status here;
* the heading the reference hardcodes on every card is that same label
* (ChildOrders.vue:48).
*
* Search covers what the reference searched: order id, customer name and
* customer mobile (`initSearch(['id','customer_id','customer.name','customer.mobile'])`).
* Ordered `id DESC`, i.e. newest first.
*/
async childOrders(
adminUserId: string | undefined,
params: { page?: number; limit?: number; search?: string | null },
) {
const agentId = this.assertExpert(adminUserId);
const page = Math.max(1, Number(params.page ?? 1));
const limit = Math.min(100, Math.max(1, Number(params.limit ?? 20)));
const search = (params.search ?? '').trim();
const scope: Prisma.OrderWhereInput = {
// The order belongs to one of THIS expert's referred customers. An
// expert can never see another expert's customers, and no id from the
// request takes part in this.
customer: { referredByAgentId: agentId },
status: OrderStatus.DELIVERED_TO_AGENT,
};
const where: Prisma.OrderWhereInput = search
? {
AND: [
scope,
{
OR: [
{ orderNumber: { contains: search, mode: 'insensitive' } },
{ customer: { fullName: { contains: search, mode: 'insensitive' } } },
{ customer: { mobile: { contains: search } } },
],
},
],
}
: scope;
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
include: {
items: true,
customer: { select: { id: true, fullName: true, mobile: true } },
},
}),
this.prisma.order.count({ where }),
]);
return {
rows: rows.map((o) => ({
id: o.id,
orderNumber: o.orderNumber,
status: o.status,
// The reference's card shows the order's total weight as `item.w`
// (ChildOrders.vue:55) and the item count as `items.length`.
w: o.totalGoldWeightGrams,
totalGoldWeightGrams: o.totalGoldWeightGrams,
createdAt: o.createdAt,
items: o.items,
customer: o.customer
? { id: o.customer.id, name: o.customer.fullName, mobile: o.customer.mobile }
: null,
})),
total,
page,
limit,
};
}
/**
* Port of `myChild` (ApiMobileController.php:184-195) — the expert's own
* referred customers, for "انتخاب مشتری".
*
* The reference searched name, mobile, phone, store_name and pasazh_name;
* all five exist here. It also returned EVERY referred customer, active or
* not — the `active()` scope is commented out at :187 — so that is
* reproduced rather than "fixed" (D4).
*
* NOT ported: the reference minted an `api_token` for each listed customer
* as a side effect (:197-201), which let an agent act AS that customer. That
* is a session-hijack primitive; this system derives every session from the
* caller's own token. See D21.
*/
async myCustomers(adminUserId: string | undefined, params: { search?: string | null }) {
const agentId = this.assertExpert(adminUserId);
const search = (params.search ?? '').trim();
const scope: Prisma.CustomerWhereInput = { referredByAgentId: agentId };
const where: Prisma.CustomerWhereInput = search
? {
AND: [
scope,
{
OR: [
{ fullName: { contains: search, mode: 'insensitive' } },
{ mobile: { contains: search } },
{ phone: { contains: search } },
{ storeName: { contains: search, mode: 'insensitive' } },
{ pasazhName: { contains: search, mode: 'insensitive' } },
],
},
],
}
: scope;
const rows = await this.prisma.customer.findMany({
where,
orderBy: { createdAt: 'desc' },
select: {
id: true,
fullName: true,
mobile: true,
phone: true,
storeName: true,
pasazhName: true,
goldCreditLimitGrams: true,
goldDebtGrams: true,
fiatDebtRial: true,
isActive: true,
createdAt: true,
},
});
return rows.map((c) => ({
id: c.id,
// legacy key, which the ported card binds to
name: c.fullName,
fullName: c.fullName,
mobile: c.mobile,
phone: c.phone,
store_name: c.storeName,
pasazh_name: c.pasazhName,
gram_level: c.goldCreditLimitGrams,
remain: c.goldDebtGrams,
rial_remain: c.fiatDebtRial,
active: c.isActive ? 1 : -1,
createdAt: c.createdAt,
}));
}
/** The expert's own profile strip, for the app header. */
async me(adminUserId: string | undefined) {
const agentId = this.assertExpert(adminUserId);
const admin = await this.prisma.adminUser.findUnique({
where: { id: agentId },
select: {
id: true,
fullName: true,
firstName: true,
lastName: true,
mobile: true,
wagePercent: true,
salesExpertProfile: {
select: { gramLevel: true, tradingCustomerId: true, city: { select: { id: true, name: true } } },
},
_count: { select: { referredCustomers: true } },
},
});
return {
id: admin?.id,
name: admin?.fullName,
fname: admin?.firstName ?? admin?.fullName,
lname: admin?.lastName ?? '',
mobile: admin?.mobile,
percent: admin?.wagePercent,
gram_level: admin?.salesExpertProfile?.gramLevel ?? null,
city: admin?.salesExpertProfile?.city
? { id: admin.salesExpertProfile.city.id, title: admin.salesExpertProfile.city.name }
: null,
customers_count: admin?._count.referredCustomers ?? 0,
/** The Customer row the expert's OWN orders land on. */
trading_customer_id: admin?.salesExpertProfile?.tradingCustomerId ?? null,
};
}
}