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:
co-authored by
Claude Opus 5
parent
53436ec855
commit
69f4ba2b29
@@ -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<string> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user