Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
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.
|
||||
*
|
||||
* `actingAsCustomerId` lets an app that was reloaded mid-impersonation render
|
||||
* its "viewing as …" bar from the server's view of the session rather than
|
||||
* trusting its own stored state.
|
||||
*/
|
||||
@CustomerAuth()
|
||||
@Get('whoami')
|
||||
whoami(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return {
|
||||
isSalesExpert: Boolean(me.adminUserId),
|
||||
type: me.type,
|
||||
actingAsCustomerId: me.actingAsCustomerId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,217 @@
|
||||
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 = {
|
||||
// Legacy getChildOrder: `where('reagent_id', auth()->id())`
|
||||
// (ApiMobileController.php:779) — the orders credited to THIS expert when
|
||||
// they were placed (Order.salesAgentId), not the customer's current
|
||||
// referrer. No id from the request takes part in this.
|
||||
salesAgentId: 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user