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
@@ -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,
};
}
}