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