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:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
@@ -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,
};
}
}