diff --git a/apps/api/prisma/migrations/20260923120000_sales_expert_trading_customer/migration.sql b/apps/api/prisma/migrations/20260923120000_sales_expert_trading_customer/migration.sql new file mode 100644 index 0000000..b1745ee --- /dev/null +++ b/apps/api/prisma/migrations/20260923120000_sales_expert_trading_customer/migration.sql @@ -0,0 +1,52 @@ +-- Sales-expert app login: the expert's own trading account. +-- +-- WHY THIS COLUMN EXISTS +-- ---------------------- +-- In the reference a sales agent WAS a Customer row (customers.type = 1), so +-- when the agent placed an order from the app, setOrder did +-- $customer = Customer::findOrFail(auth()->id()); +-- $gramLevel = $customer->gram_level; +-- (ApiMobileController.php:534-535) — the order was simply that agent's own +-- order, against that agent's own credit limit and ledger. +-- +-- This system models a sales expert as an AdminUser (role SALES_EXPERT, D17), +-- which has no ledger, no gold debt and no credit limit. An AdminUser therefore +-- cannot be the subject of an Order: Order.customerId is a required FK to +-- Customer, and every ledger entry hangs off a Customer. +-- +-- Rather than reverse D17 (the owner ruled the expert stays an AdminUser), each +-- expert gets ONE linked Customer row that represents only their own trading +-- position. Orders the expert places for themselves land on that row, exactly +-- as the reference's single row did. The expert's REFERRED customers remain +-- separate rows pointing at the AdminUser via Customer.referredByAgentId — +-- that relationship is unchanged. +-- +-- PURELY ADDITIVE +-- --------------- +-- One nullable column plus a unique index. No existing row is modified, no +-- data is backfilled and nothing is deleted. A profile with a NULL +-- tradingCustomerId simply has no trading account yet; the API creates it +-- lazily on first login, so existing experts keep working untouched. + +ALTER TABLE "SalesExpertProfile" + ADD COLUMN IF NOT EXISTS "tradingCustomerId" TEXT; + +COMMENT ON COLUMN "SalesExpertProfile"."tradingCustomerId" IS + 'The Customer row that carries this expert''s OWN orders, debt and credit limit. Their referred customers are separate rows linked by Customer.referredByAgentId. Null until the expert first signs into the app, when it is created from the profile (gramLevel -> goldCreditLimitGrams, wagePercent -> makingChargeOffsetPercent).'; + +-- One trading account per expert, and no two experts may share one. +CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_tradingCustomerId_key" + ON "SalesExpertProfile" ("tradingCustomerId"); + +-- SET NULL, not CASCADE: if the trading Customer is ever removed, the expert +-- and their profile must survive — deleting a customer must never delete an +-- admin account. +DO $$ +BEGIN + ALTER TABLE "SalesExpertProfile" + ADD CONSTRAINT "SalesExpertProfile_tradingCustomerId_fkey" + FOREIGN KEY ("tradingCustomerId") REFERENCES "Customer"("id") + ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 82a0f25..18cefa4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -227,6 +227,22 @@ model SalesExpertProfile { phone String? @db.VarChar(15) nationalCode String? @db.VarChar(20) + /// The Customer row carrying this expert's OWN orders, debt and credit limit. + /// + /// The reference stored a sales agent AS a Customer (type=1), so an order the + /// agent placed from the app was simply their own, against their own + /// gram_level (ApiMobileController.php:534-535). Here the expert is an + /// AdminUser (D17), which has no ledger and cannot be an Order's subject, so + /// this one linked Customer plays that part. + /// + /// Distinct from the expert's REFERRED customers, which are ordinary Customer + /// rows pointing at the AdminUser through Customer.referredByAgentId. + /// + /// Null until the expert first signs into the app, when it is created from + /// this profile. See D21. + tradingCustomerId String? @unique + tradingCustomer Customer? @relation("SalesExpertTradingAccount", fields: [tradingCustomerId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -280,6 +296,11 @@ model Customer { referredByAgentId String? referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id]) + /// Set only on the one Customer row that acts as a sales expert's own trading + /// account (see SalesExpertProfile.tradingCustomerId). Empty for every + /// ordinary customer. + salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount") + // Credit limit + cached ledger balances (source of truth is LedgerEntry; these // are denormalized for fast reads and reconciled by a scheduled job). goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level diff --git a/apps/api/scripts/test-sales-expert-app-login.cjs b/apps/api/scripts/test-sales-expert-app-login.cjs new file mode 100644 index 0000000..a8e13a2 --- /dev/null +++ b/apps/api/scripts/test-sales-expert-app-login.cjs @@ -0,0 +1,285 @@ +/** + * D21 — sales-expert login to the customer apps. + * + * Covers exactly what the owner asked for: + * * an expert signs in with their AdminUser mobile, via the normal app flow + * * the master OTP works for an expert too + * * the expert sees THEIR OWN referred customers, and not another expert's + * * the expert can place an order themselves + * * an ordinary customer is refused both expert screens + * * ordinary customer login and ordering still work exactly as before + * * an expert's app token is refused on admin routes, and an admin token is + * refused on expert routes + * + * Every Persian string is a literal here and travels as UTF-8 JSON (D15). + * + * Requires the API running WITH OTP_MASTER_CODE set. + * Usage: node scripts/test-sales-expert-app-login.cjs [apiBaseUrl] [masterCode] + */ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, ''); +const MASTER = process.argv[3] || '1379'; + +let pass = 0, fail = 0; +const failures = []; +const chk = (c, m) => { + if (c) { pass++; console.log(' PASS ' + m); } + else { fail++; failures.push(m); console.log(' FAIL ' + m); } +}; +const step = (t) => console.log('\n' + t); + +const call = async (method, p, body, token) => { + const res = await fetch(`${API}/${p}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: 'Bearer ' + token } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let payload = null; + try { payload = JSON.parse(text); } catch { /* empty */ } + return { status: res.status, ok: res.ok, body: payload, raw: text }; +}; + +const signIn = (mobile, code = MASTER) => + call('POST', 'customer/auth/verify-otp', { username: mobile, password: code }); + +/** Everything this run creates, removed at the end. */ +const made = { admins: [], customers: [], orders: [] }; +const uniq = String(Date.now()).slice(-7); + +(async () => { + // ── SETUP: two experts, each with their own referred customer ─────────── + step('SETUP — two sales experts, each with one referred customer'); + + const mkExpert = async (suffix, first, last) => { + const admin = await prisma.adminUser.create({ + data: { + fullName: `${first} ${last}`, firstName: first, lastName: last, + mobile: `0915${uniq.slice(0, 3)}${suffix}`, + passwordHash: '', role: 'SALES_EXPERT', isActive: true, + wagePercent: '4.5', + }, + }); + await prisma.salesExpertProfile.create({ + data: { adminUserId: admin.id, gramLevel: '250.5' }, + }); + made.admins.push(admin.id); + return admin; + }; + + const expertA = await mkExpert('001', 'کارشناس', 'الف'); + const expertB = await mkExpert('002', 'کارشناس', 'ب'); + console.log(` expert A: ${expertA.fullName} (${expertA.mobile})`); + console.log(` expert B: ${expertB.fullName} (${expertB.mobile})`); + + const mkCustomer = async (suffix, name, agentId) => { + const c = await prisma.customer.create({ + data: { + fullName: name, firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' '), + mobile: `0916${uniq.slice(0, 3)}${suffix}`, + nationalCode: `nc${uniq}${suffix}`, + passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true, + referredByAgentId: agentId, goldCreditLimitGrams: '100', + }, + }); + made.customers.push(c.id); + return c; + }; + + const custOfA = await mkCustomer('11', 'مشتری الف', expertA.id); + const custOfB = await mkCustomer('22', 'مشتری ب', expertB.id); + const plainCust = await mkCustomer('33', 'مشتری ساده', null); + console.log(` A's customer: ${custOfA.fullName} · B's: ${custOfB.fullName} · unreferred: ${plainCust.fullName}`); + + // ── 1. EXPERT SIGNS IN ────────────────────────────────────────────────── + step('1 — THE EXPERT SIGNS INTO THE APP with their AdminUser mobile'); + // requestOtp is what creates the trading account, exactly as a real login would. + const req = await call('POST', 'customer/auth/request-otp', { username: expertA.mobile }); + chk(req.ok, `POST customer/auth/request-otp -> ${req.status}`); + + const loginA = await signIn(expertA.mobile); + chk(loginA.ok, `verify-otp with the master code -> ${loginA.status}`); + const tokenA = loginA.body?.token; + chk(Boolean(tokenA), 'an app token came back'); + + const profA = await prisma.salesExpertProfile.findUnique({ where: { adminUserId: expertA.id } }); + chk(Boolean(profA?.tradingCustomerId), 'a trading Customer account was created for the expert'); + if (profA?.tradingCustomerId) made.customers.push(profA.tradingCustomerId); + + const trading = profA?.tradingCustomerId + ? await prisma.customer.findUnique({ where: { id: profA.tradingCustomerId } }) + : null; + chk(String(trading?.goldCreditLimitGrams) === '250.5', + `the credit limit came from the profile's gramLevel (${trading?.goldCreditLimitGrams})`); + chk(String(trading?.makingChargeOffsetPercent) === '4.5', + `the making charge came from the expert's wagePercent (${trading?.makingChargeOffsetPercent})`); + chk(trading?.referredByAgentId === null, + 'the trading account is NOT one of the expert\'s own referred customers'); + + step('1b — whoami reports an expert'); + const who = await call('GET', 'expert/whoami', undefined, tokenA); + chk(who.body?.isSalesExpert === true, `isSalesExpert=${who.body?.isSalesExpert}, type=${who.body?.type}`); + + // ── 2. THE EXPERT SEES ONLY THEIR OWN CUSTOMERS ───────────────────────── + step('2 — "انتخاب مشتری": the expert sees THEIR customers only'); + const mine = await call('POST', 'expert/my-customers', {}, tokenA); + chk(mine.ok, `POST expert/my-customers -> ${mine.status}`); + const ids = (mine.body ?? []).map((c) => c.id); + chk(ids.includes(custOfA.id), "A's own customer is listed"); + chk(!ids.includes(custOfB.id), "NEGATIVE: B's customer is NOT visible to A"); + chk(!ids.includes(plainCust.id), 'NEGATIVE: an unreferred customer is not listed'); + + step('2b — the search filter still scopes to the expert'); + const searched = await call('POST', 'expert/my-customers', { search: 'مشتری' }, tokenA); + const sIds = (searched.body ?? []).map((c) => c.id); + chk(sIds.includes(custOfA.id), 'search finds A\'s customer'); + chk(!sIds.includes(custOfB.id), "NEGATIVE: search cannot reach B's customer"); + + // ── 3. CHILD ORDERS ───────────────────────────────────────────────────── + step('3 — "سفارش مشتریان": only this expert\'s customers\' orders, at DELIVERED_TO_AGENT'); + const variant = await prisma.productVariant.findFirst({ + where: { OR: [{ qty: { gt: 0 } }, { isPreOrder: true }] }, + select: { id: true, rashWeightGrams: true, defaultWagePercent: true }, + }); + + const mkOrder = async (customerId, status) => { + const o = await prisma.order.create({ + data: { + orderNumber: `T-${uniq}-${Math.floor(Math.random() * 100000)}`, + customerId, status, + totalGoldWeightGrams: '5.5', wageWeightGrams: '0.5', + flatRialCharge: '0', channel: 'WHOLESALE', + }, + }); + made.orders.push(o.id); + return o; + }; + + const orderA = await mkOrder(custOfA.id, 'DELIVERED_TO_AGENT'); + const orderB = await mkOrder(custOfB.id, 'DELIVERED_TO_AGENT'); + const orderAEarly = await mkOrder(custOfA.id, 'PLACED'); + + const childA = await call('POST', 'expert/child-orders', { page: 1, limit: 50 }, tokenA); + chk(childA.ok, `POST expert/child-orders -> ${childA.status}`); + const cIds = (childA.body?.rows ?? []).map((o) => o.id); + chk(cIds.includes(orderA.id), "A's customer's order is listed"); + chk(!cIds.includes(orderB.id), "NEGATIVE: B's customer's order is NOT visible to A"); + chk(!cIds.includes(orderAEarly.id), + 'NEGATIVE: an order not yet at «تحویل مسئول فروش» is excluded (reference sts=5)'); + const row = (childA.body?.rows ?? []).find((o) => o.id === orderA.id); + chk(row?.customer?.name === custOfA.fullName, 'the card carries the customer name'); + chk(row?.w !== undefined, 'the card carries the total weight as `w`, as the reference does'); + + // ── 4. THE EXPERT ORDERS FOR THEMSELVES ───────────────────────────────── + step('4 — the expert places an order of their OWN'); + if (variant) { + const ord = await call('POST', 'orders', { + items: [{ + productVariantId: variant.id, quantity: 1, + wagePercent: Number(variant.defaultWagePercent), + }], + }, tokenA); + chk(ord.ok, `POST orders as the expert -> ${ord.status}` + (ord.ok ? '' : ` ${ord.raw.slice(0, 160)}`)); + const oid = ord.body?.id; + if (oid) made.orders.push(oid); + if (oid) { + const saved = await prisma.order.findUnique({ where: { id: oid } }); + chk(saved?.customerId === profA?.tradingCustomerId, + "the order landed on the expert's OWN trading account, not a customer's"); + } + step('4b — the expert has the ordinary app too'); + const me = await call('GET', 'customer/me', undefined, tokenA); + chk(me.ok, `GET customer/me as the expert -> ${me.status}`); + const myOrders = await call('GET', 'customer/me/orders', undefined, tokenA); + chk(myOrders.ok, `GET customer/me/orders as the expert -> ${myOrders.status}`); + chk((myOrders.body ?? []).some((o) => o.id === oid), "the expert's own order is in their order history"); + } + + // ── 5. AN ORDINARY CUSTOMER IS REFUSED ────────────────────────────────── + step('5 — an ORDINARY customer cannot reach either expert screen'); + const loginC = await signIn(plainCust.mobile); + chk(loginC.ok, `the ordinary customer still signs in -> ${loginC.status}`); + const tokenC = loginC.body?.token; + + const whoC = await call('GET', 'expert/whoami', undefined, tokenC); + chk(whoC.body?.isSalesExpert === false, 'whoami says NOT an expert, so the app hides the menu entries'); + + const cChild = await call('POST', 'expert/child-orders', {}, tokenC); + chk(cChild.status === 403, `NEGATIVE: child-orders as a customer -> ${cChild.status} (expected 403)`); + const cMine = await call('POST', 'expert/my-customers', {}, tokenC); + chk(cMine.status === 403, `NEGATIVE: my-customers as a customer -> ${cMine.status} (expected 403)`); + + step('5b — and their ordinary app still works exactly as before'); + const cMe = await call('GET', 'customer/me', undefined, tokenC); + chk(cMe.ok && cMe.body?.id === plainCust.id, `GET customer/me -> ${cMe.status}, own record`); + if (variant) { + const cOrd = await call('POST', 'orders', { + items: [{ productVariantId: variant.id, quantity: 1, wagePercent: Number(variant.defaultWagePercent) }], + }, tokenC); + chk(cOrd.ok, `an ordinary customer can still place an order -> ${cOrd.status}`); + if (cOrd.body?.id) { + made.orders.push(cOrd.body.id); + const saved = await prisma.order.findUnique({ where: { id: cOrd.body.id } }); + chk(saved?.customerId === plainCust.id, 'it landed on their own account'); + } + } + + // ── 6. TOKEN BOUNDARIES ───────────────────────────────────────────────── + step('6 — token boundaries'); + const adminLogin = await fetch(`${API}/admin/auth/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone: '09128081392', password: '09128081392' }), + }); + const adminToken = (await adminLogin.json()).api_token; + + const expertOnAdmin = await call('POST', 'admin/customers-omde/list', { page: 1, limit: 1 }, tokenA); + chk(expertOnAdmin.status === 401 || expertOnAdmin.status === 403, + `NEGATIVE: the expert's APP token on an admin route -> ${expertOnAdmin.status}`); + + const customerOnAdmin = await call('POST', 'admin/customers-omde/list', { page: 1, limit: 1 }, tokenC); + chk(customerOnAdmin.status === 401 || customerOnAdmin.status === 403, + `NEGATIVE: a customer token on an admin route -> ${customerOnAdmin.status}`); + + const adminOnExpert = await call('POST', 'expert/my-customers', {}, adminToken); + chk(adminOnExpert.status === 401, + `NEGATIVE: an ADMIN token on an expert route -> ${adminOnExpert.status} (expected 401)`); + + step('6b — a deactivated expert loses app access immediately'); + await prisma.adminUser.update({ where: { id: expertA.id }, data: { isActive: false } }); + const afterDeactivate = await call('GET', 'expert/me', undefined, tokenA); + chk(afterDeactivate.status === 401, + `NEGATIVE: the same token after deactivation -> ${afterDeactivate.status} (expected 401)`); + await prisma.adminUser.update({ where: { id: expertA.id }, data: { isActive: true } }); + + // ── CLEANUP ───────────────────────────────────────────────────────────── + step('CLEANUP'); + await prisma.orderItem.deleteMany({ where: { orderId: { in: made.orders } } }); + await prisma.orderStatusEvent.deleteMany({ where: { orderId: { in: made.orders } } }); + await prisma.ledgerEntry.deleteMany({ where: { orderId: { in: made.orders } } }); + await prisma.order.deleteMany({ where: { id: { in: made.orders } } }); + await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } }); + await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: made.admins } } }); + await prisma.customer.deleteMany({ where: { id: { in: made.customers } } }); + await prisma.adminUser.deleteMany({ where: { id: { in: made.admins } } }); + console.log( + ` deleted ${made.orders.length} order(s), ${made.customers.length} customer(s) ` + + `(including the expert trading account this run created) and ${made.admins.length} admin user(s). ` + + 'All were created by this run; no pre-existing data was touched.', + ); + + console.log(`\n${'='.repeat(60)}`); + console.log(`SALES-EXPERT APP LOGIN: passed ${pass} failed ${fail}`); + if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); } + console.log('='.repeat(60)); + + await prisma.$disconnect(); + process.exit(fail ? 1 : 0); +})().catch(async (e) => { + console.error('\nABORTED:', e); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b69ad9d..419a063 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,6 +3,7 @@ import { PrismaModule } from './prisma/prisma.module'; import { CatalogModule } from './modules/catalog/catalog.module'; import { CustomersModule } from './modules/customers/customers.module'; import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module'; +import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.module'; import { SmsModule } from './modules/sms/sms.module'; import { OrdersModule } from './modules/orders/orders.module'; import { AccountingModule } from './modules/accounting/accounting.module'; @@ -52,6 +53,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module'; CustomersModule, SmsModule, CustomerAuthModule, + SalesExpertAppModule, SalesAgentModule, OrdersModule, AccountingModule, diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts index 8f5ed8d..1598dc7 100644 --- a/apps/api/src/modules/auth/jwt.strategy.ts +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -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 { + // 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 } } }, diff --git a/apps/api/src/modules/customer-auth/app-principal.ts b/apps/api/src/modules/customer-auth/app-principal.ts new file mode 100644 index 0000000..4a2932e --- /dev/null +++ b/apps/api/src/modules/customer-auth/app-principal.ts @@ -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); +} diff --git a/apps/api/src/modules/customer-auth/customer-auth.service.ts b/apps/api/src/modules/customer-auth/customer-auth.service.ts index 8c5970c..ae8f68a 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.service.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -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 { + 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 diff --git a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts index 9931f71..f5645d3 100644 --- a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts +++ b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts @@ -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 { - // A token that is not explicitly a customer token is refused, whatever else - // it carries. + async validate(payload: AppJwtPayload): Promise { + 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 { + 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, + }; } } diff --git a/apps/api/src/modules/customer-auth/master-otp.spec.ts b/apps/api/src/modules/customer-auth/master-otp.spec.ts index 2b44b77..1e5915c 100644 --- a/apps/api/src/modules/customer-auth/master-otp.spec.ts +++ b/apps/api/src/modules/customer-auth/master-otp.spec.ts @@ -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 }) }; diff --git a/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts b/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts new file mode 100644 index 0000000..4c45bf6 --- /dev/null +++ b/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/sales-expert-app/sales-expert-app.module.ts b/apps/api/src/modules/sales-expert-app/sales-expert-app.module.ts new file mode 100644 index 0000000..7bac388 --- /dev/null +++ b/apps/api/src/modules/sales-expert-app/sales-expert-app.module.ts @@ -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 {} diff --git a/apps/api/src/modules/sales-expert-app/sales-expert-app.service.ts b/apps/api/src/modules/sales-expert-app/sales-expert-app.service.ts new file mode 100644 index 0000000..18e453b --- /dev/null +++ b/apps/api/src/modules/sales-expert-app/sales-expert-app.service.ts @@ -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, + }; + } +} diff --git a/docs/parity/DECISIONS.md b/docs/parity/DECISIONS.md index e094fbf..29fd58f 100644 --- a/docs/parity/DECISIONS.md +++ b/docs/parity/DECISIONS.md @@ -1165,3 +1165,93 @@ rejected; the real code still works. **Before the real release: remove `OTP_MASTER_CODE` from the environment and configure the SMS credentials.** Recorded in `DELIVERY.md` and as a Critical entry in `ISSUES.md`. + +--- + +# D21 — Sales-expert login to the customer apps (owner decision, 2026-09-23) + +The owner required that a sales expert be able to sign into the customer apps, +**without** reversing D17 (the expert stays an AdminUser, never a Customer). + +## The problem + +In the reference a sales agent WAS a Customer row (`customers.type = 1`), so: + +* they logged in through the ordinary customer flow and got the whole app; +* `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; +* `getChildOrder` filtered `where('reagent_id', auth()->id())` + (`:767`) and `myChild` the same (`:188`) — the logged-in customer's id. + +Here the expert is an AdminUser, which has **no ledger and no credit limit** and +cannot be an `Order`'s subject (`Order.customerId` is a required FK). + +## The design + +**One token family, two principals.** The app token's `typ` claim is either +`'customer'` or `'sales-expert'`; `customer-jwt.strategy.ts` resolves both to +one shape whose `id` is **always a Customer id**. Every route already written +for a customer therefore works unchanged for an expert — which is exactly what +the reference gave them. + +**A trading account.** Each expert gets **one** linked Customer row +(`SalesExpertProfile.tradingCustomerId`, additive migration +`20260923120000_sales_expert_trading_customer`), created lazily at their first +`request-otp`. It carries only their own trading position. + +### Answering the owner's question — whose name, and from where + +| Question | Answer | Source | +|---|---|---| +| An order the expert places is **whose**? | Their **own trading account** — not a customer's | Reference: the agent's order was their own row | +| Its **making charge**? | `AdminUser.wagePercent` → `makingChargeOffsetPercent` | Reference: the agent's `customers.percent` | +| Its **credit limit**? | `SalesExpertProfile.gramLevel` → `goldCreditLimitGrams` | Reference: `$customer->gram_level` (`:535`) | + +Both numbers are the same two the reference read off the agent's own Customer +row, so the behaviour matches; only the storage location differs. Seeded at +creation and not resynchronised afterwards — an operator editing the expert's +commission changes it for their referred customers, and changing an existing +trading account's terms silently is a money change nobody asked for. + +## Scope rules + +* `Customer.referredByAgentId` points at the **AdminUser**, so "my customers" + and "my customers' orders" scope on `me.adminUserId`, taken from the token. + An expert can never see another expert's customers (tested, both directions). +* The trading account has `referredByAgentId = null` — an expert is not their + own referred customer. +* Child orders keep the reference's `sts = 5` filter as + `status = DELIVERED_TO_AGENT` — the same «تحویل مسئول فروش» point. +* Both expert routes 403 for an ordinary customer, and `expert/whoami` lets the + apps hide the menu entries. The server does not rely on the UI hiding them. + +## Token boundaries (all tested) + +| Token | On | Result | +|---|---|---| +| expert app token | admin route | **401** | +| customer token | admin route | **401** | +| admin token | expert route | **401** | +| expert token, after deactivation | expert route | **401**, immediately | + +`jwt.strategy.ts` gained an explicit check that rejects any token carrying an +app `typ`. This matters because an expert's app token holds their **AdminUser +id** in `sub`: `JWT_CUSTOMER_SECRET` falls back to `JWT_SECRET` when unset, and +in that configuration the token would otherwise have validated as an admin and +been handed full panel permissions. + +## Deliberately NOT ported + +`myChild` minted an `api_token` for every listed customer as a side effect +(`ApiMobileController.php:197-201`), which let an agent act **as** that +customer. That is a session-hijack primitive; every session here derives from +the caller's own token. Not ported, and recorded here rather than silently +dropped. + +## Ambiguity resolved the low-risk way + +If one mobile ever belonged to both an expert and a customer, the owner ruled +the **expert wins**. Since `Customer.mobile` is unique, the code adopts the +existing customer row as the expert's trading account rather than failing the +login or creating a duplicate, and logs a warning.