feat(auth): one mobile, one person across sales experts and customers
Experts (AdminUser) and customers (Customer) sign in to the same apps by mobile but live in different tables, so the database cannot make the number unique across them. A number could be given to an expert and to a customer, and the login then silently merged the two (the customer row was adopted as the expert's trading account), which left the expert on a customer session and the panel's name edits unreachable. The reference rejected a duplicate at entry (`unique:customers`, the agent being a customers row too); this does the same where a number is typed in: - customer create/update (wholesale, banker, walk-in): refused when an ACTIVE expert holds the number; - expert create/update: refused when a customer holds it (the expert's own trading account excepted); - the app's self sign-up: refused when an active expert holds it. Only a number being created or CHANGED is checked, so existing duplicates keep signing in and stay editable; the login's "expert wins" adoption is untouched. Plain equality on the two unique columns, normalised with faToEn, so both lookups are index hits. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
5ddb225813
commit
5f3b00497e
@@ -0,0 +1,68 @@
|
||||
import { AdminRole } from '@prisma/client';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* One mobile, one person — across the two tables the apps sign in from.
|
||||
*
|
||||
* A sales expert (AdminUser) and a customer (Customer) both log in to the apps
|
||||
* with their mobile, but they live in different tables, so the database cannot
|
||||
* make the number unique across them. These checks do it where a number is
|
||||
* typed in: the reference rejected a duplicate the same way (`unique:customers`
|
||||
* on create and update, with the agent a customers row too).
|
||||
*
|
||||
* Plain equality on the unique columns, so both lookups are index hits. The
|
||||
* mobile must already be normalised (`faToEn`), as it is when stored.
|
||||
*
|
||||
* Nothing here reads or changes a row that already exists: callers only ask
|
||||
* when a number is being CREATED or CHANGED, so an existing duplicate keeps
|
||||
* logging in and stays editable.
|
||||
*/
|
||||
type Db = Pick<PrismaService, 'adminUser' | 'customer'>;
|
||||
|
||||
export const EXPERT_HOLDS_MOBILE = 'این شماره موبایل قبلاً برای یک کارشناس فروش ثبت شده است';
|
||||
export const CUSTOMER_HOLDS_MOBILE = 'این شماره موبایل قبلاً برای یک مشتری ثبت شده است';
|
||||
|
||||
/**
|
||||
* An ACTIVE sales expert holds this number. Active only, because that is who the
|
||||
* app login treats as an expert (`resolveExpertPrincipal`); a deactivated
|
||||
* expert's number still works as a customer today and must keep doing so.
|
||||
*
|
||||
* `exceptTradingCustomerId` skips the expert whose own trading account that
|
||||
* customer is — the row is theirs, so it may carry their number.
|
||||
*/
|
||||
export async function activeExpertHoldsMobile(
|
||||
db: Db,
|
||||
mobile: string,
|
||||
exceptTradingCustomerId?: string,
|
||||
): Promise<boolean> {
|
||||
const row = await db.adminUser.findFirst({
|
||||
where: {
|
||||
mobile,
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
isActive: true,
|
||||
...(exceptTradingCustomerId
|
||||
? { NOT: { salesExpertProfile: { is: { tradingCustomerId: exceptTradingCustomerId } } } }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer row (any state) holds this number. Any state, because an expert
|
||||
* whose number sits on an inactive or archived customer cannot sign in at all.
|
||||
*
|
||||
* `exceptCustomerId` is the expert's own trading account.
|
||||
*/
|
||||
export async function customerHoldsMobile(
|
||||
db: Db,
|
||||
mobile: string,
|
||||
exceptCustomerId?: string | null,
|
||||
): Promise<boolean> {
|
||||
const row = await db.customer.findFirst({
|
||||
where: { mobile, ...(exceptCustomerId ? { id: { not: exceptCustomerId } } : {}) },
|
||||
select: { id: true },
|
||||
});
|
||||
return row !== null;
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import { AdminCustomersService } from './admin-customers.service';
|
||||
* explicit OTP-only marker.
|
||||
*/
|
||||
describe('AdminCustomersService', () => {
|
||||
function build(current: Record<string, unknown> = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }) {
|
||||
function build(
|
||||
current: Record<string, unknown> = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED },
|
||||
expertHolder: { id: string } | null = null,
|
||||
) {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const tx = {
|
||||
customer: {
|
||||
@@ -26,11 +29,13 @@ describe('AdminCustomersService', () => {
|
||||
findUnique: jest.fn(async () => current),
|
||||
},
|
||||
};
|
||||
const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
|
||||
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() };
|
||||
// the cross-table check: is there an active sales expert on this number
|
||||
const adminUser = { findFirst: jest.fn(async () => expertHolder) };
|
||||
const prisma = { ...tx, adminUser, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
|
||||
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn(), customerEdited: jest.fn() };
|
||||
const sms = { send: jest.fn(async () => undefined) };
|
||||
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never, {} as never);
|
||||
return { service, writes, accounting };
|
||||
return { service, writes, accounting, adminUser };
|
||||
}
|
||||
|
||||
describe('verify()', () => {
|
||||
@@ -157,4 +162,43 @@ describe('AdminCustomersService', () => {
|
||||
expect(JSON.stringify(where)).not.toContain('isActive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('one mobile, one person (sales experts sign in with theirs too)', () => {
|
||||
const payload = { fname: 'علی', mobile: '09120000000', percent: '3' };
|
||||
|
||||
it('create() refuses a number an active sales expert holds, and stores nothing', async () => {
|
||||
const { service, writes } = build(undefined, { id: 'exp1' });
|
||||
await expect(service.create(payload)).rejects.toThrow('این شماره موبایل قبلاً برای یک کارشناس فروش ثبت شده است');
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('create() normalises the digits before asking, so a Persian-typed number is checked as stored', async () => {
|
||||
const { service, adminUser } = build();
|
||||
await service.create({ ...payload, mobile: '۰۹۱۲۰۰۰۰۰۰۰' });
|
||||
expect(adminUser.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: expect.objectContaining({ mobile: '09120000000', isActive: true }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('create() on a free number goes through', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.create(payload);
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('update() refuses a CHANGED number an expert holds', async () => {
|
||||
const { service, writes } = build({ id: 'c1', mobile: '09121111111' }, { id: 'exp1' });
|
||||
await expect(service.update('c1', { mobile: '09120000000' })).rejects.toThrow(
|
||||
'این شماره موبایل قبلاً برای یک کارشناس فروش ثبت شده است',
|
||||
);
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('update() does not ask when the number is unchanged, so an existing duplicate stays editable', async () => {
|
||||
const { service, writes, adminUser } = build({ id: 'c1', mobile: '09120000000' }, { id: 'exp1' });
|
||||
await service.update('c1', { mobile: '09120000000', fname: 'علی' });
|
||||
expect(adminUser.findFirst).not.toHaveBeenCalled();
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SmsService } from '../sms/sms.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { AccountingConfigService } from '../accounting-sync/accounting-config.service';
|
||||
import { brandName, brandSiteUrl } from '../../common/brand';
|
||||
import { activeExpertHoldsMobile, EXPERT_HOLDS_MOBILE } from '../../common/mobile-owner';
|
||||
|
||||
/**
|
||||
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
|
||||
@@ -818,6 +819,12 @@ export class AdminCustomersService {
|
||||
throw new BadRequestException('نام و موبایل الزامی است');
|
||||
}
|
||||
|
||||
// One mobile, one person: an active sales expert signs in with this number
|
||||
// too. (A number another CUSTOMER holds is refused by the unique index.)
|
||||
if (await activeExpertHoldsMobile(this.prisma, mobile)) {
|
||||
throw new BadRequestException(EXPERT_HOLDS_MOBILE);
|
||||
}
|
||||
|
||||
// D9 — the reference makes `percent` required on create
|
||||
// (AdminController.php:354) with the stock Laravel message, and its
|
||||
// attribute has no Persian translation, so the text is verbatim what the
|
||||
@@ -913,7 +920,16 @@ export class AdminCustomersService {
|
||||
// submitted mobile entirely on edit (AdminController.php:348); D19 ruled
|
||||
// that lock NOT ported, so the field stays editable here and is normalised
|
||||
// instead. Logged in ISSUES as PERS-01.
|
||||
if (data.mobile !== undefined) patch.mobile = faToEn(String(data.mobile));
|
||||
if (data.mobile !== undefined) {
|
||||
const mobile = faToEn(String(data.mobile));
|
||||
patch.mobile = mobile;
|
||||
// Asked only when the number CHANGES: a customer who already shares a
|
||||
// number with an expert (older data) stays editable and keeps signing in.
|
||||
// A trading account may carry its own expert's number.
|
||||
if (mobile !== existing.mobile && (await activeExpertHoldsMobile(this.prisma, mobile, id))) {
|
||||
throw new BadRequestException(EXPERT_HOLDS_MOBILE);
|
||||
}
|
||||
}
|
||||
if (data.national_code !== undefined) patch.nationalCode = nationalCodeOrNull(data.national_code);
|
||||
if (data.city_id !== undefined) {
|
||||
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
|
||||
|
||||
@@ -38,7 +38,12 @@ describe('AdminUsersService — expert edit reaches the trading account', () =>
|
||||
},
|
||||
})),
|
||||
},
|
||||
customer: { update: customerUpdate, findUnique: jest.fn(async () => customerHolder) },
|
||||
customer: {
|
||||
update: customerUpdate,
|
||||
findUnique: jest.fn(async () => customerHolder),
|
||||
// the cross-table check: does a customer (not the expert's own account) hold the number
|
||||
findFirst: jest.fn(async () => customerHolder),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
...tx,
|
||||
@@ -78,11 +83,32 @@ describe('AdminUsersService — expert edit reaches the trading account', () =>
|
||||
await free.service.update('exp1', { mobile: '09120001111' });
|
||||
expect(free.customerUpdate).toHaveBeenCalledWith({ where: { id: 'cust1' }, data: { mobile: '09120001111' } });
|
||||
|
||||
});
|
||||
|
||||
it('refuses a new mobile a customer already holds, and writes nothing', async () => {
|
||||
const taken = build({ id: 'someone-else' });
|
||||
await taken.service.update('exp1', { mobile: '09120001111' });
|
||||
await expect(taken.service.update('exp1', { mobile: '09120001111' })).rejects.toThrow(
|
||||
'این شماره موبایل قبلاً برای یک مشتری ثبت شده است',
|
||||
);
|
||||
expect(taken.customerUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not ask about a mobile that is not changing, so an existing duplicate stays editable', async () => {
|
||||
const { service, customerUpdate } = build({ id: 'someone-else' });
|
||||
await expect(service.update('exp1', { mobile: '09902560722', fname: 'سعید1', lname: 'اسماعیلی1' })).resolves.toBeDefined();
|
||||
expect(customerUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'cust1' },
|
||||
data: { firstName: 'سعید1', lastName: 'اسماعیلی1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to create an expert on a number a customer holds', async () => {
|
||||
const { service } = build({ id: 'someone-else' });
|
||||
await expect(
|
||||
service.create({ fname: 'علی', lname: 'رضایی', mobile: '09125550000', password: 'secret1', role: 'SALES_EXPERT' }),
|
||||
).rejects.toThrow('این شماره موبایل قبلاً برای یک مشتری ثبت شده است');
|
||||
});
|
||||
|
||||
it('writes nothing to the trading account when nothing it mirrors changed', async () => {
|
||||
const { service, customerUpdate } = build();
|
||||
await service.update('exp1', { address: 'تهران' });
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { ALL_RIDS } from '../auth/acl.constants';
|
||||
import { SUPER_ADMIN_MOBILE, isSuperAdmin } from '../auth/super-admin';
|
||||
import { CUSTOMER_HOLDS_MOBILE, customerHoldsMobile } from '../../common/mobile-owner';
|
||||
|
||||
/**
|
||||
* SEC-07: the rids a role may hold — every rid in the catalogue and each of
|
||||
@@ -300,6 +301,10 @@ export class AdminUsersService {
|
||||
// Unique within the expert table only: the same number may also be an admin.
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
// ...and a customer signs in to the same apps with theirs: one mobile, one person.
|
||||
if (await customerHoldsMobile(this.prisma, mobile)) {
|
||||
throw new BadRequestException(CUSTOMER_HOLDS_MOBILE);
|
||||
}
|
||||
|
||||
return this.prisma.adminUser.create({
|
||||
data: {
|
||||
@@ -456,6 +461,12 @@ export class AdminUsersService {
|
||||
if (mobile !== existing.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
// Only a CHANGED number is asked about, so an expert who already shares
|
||||
// theirs with a customer (older data) stays editable. Their own trading
|
||||
// account may hold it.
|
||||
if (await customerHoldsMobile(this.prisma, mobile, existing.salesExpertProfile?.tradingCustomerId)) {
|
||||
throw new BadRequestException(CUSTOMER_HOLDS_MOBILE);
|
||||
}
|
||||
}
|
||||
patch.mobile = mobile;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from
|
||||
import { faToEn } from './digits';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { brandName } from '../../common/brand';
|
||||
import { activeExpertHoldsMobile } from '../../common/mobile-owner';
|
||||
import type { RegisterDto } from './dto/register.dto';
|
||||
|
||||
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
|
||||
@@ -235,7 +236,7 @@ export class CustomerAuthService {
|
||||
const fname = dto.fname.trim();
|
||||
const lname = dto.lname.trim();
|
||||
const existing = await this.prisma.customer.findUnique({ where: { mobile }, select: { id: true } });
|
||||
if (existing) {
|
||||
if (existing || (await activeExpertHoldsMobile(this.prisma, mobile))) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user