diff --git a/apps/api/src/common/mobile-owner.ts b/apps/api/src/common/mobile-owner.ts new file mode 100644 index 0000000..9ccbdff --- /dev/null +++ b/apps/api/src/common/mobile-owner.ts @@ -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; + +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 { + 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 { + const row = await db.customer.findFirst({ + where: { mobile, ...(exceptCustomerId ? { id: { not: exceptCustomerId } } : {}) }, + select: { id: true }, + }); + return row !== null; +} diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts b/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts index 3671ebe..c6374af 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts @@ -11,7 +11,10 @@ import { AdminCustomersService } from './admin-customers.service'; * explicit OTP-only marker. */ describe('AdminCustomersService', () => { - function build(current: Record = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }) { + function build( + current: Record = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }, + expertHolder: { id: string } | null = null, + ) { const writes: Record[] = []; 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); + }); + }); }); diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.ts b/apps/api/src/modules/admin-customers/admin-customers.service.ts index 9bc089a..f9ac8e0 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts @@ -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 }; diff --git a/apps/api/src/modules/admin-users/admin-users.expert-sync.spec.ts b/apps/api/src/modules/admin-users/admin-users.expert-sync.spec.ts index dca5fb6..9ddfcfb 100644 --- a/apps/api/src/modules/admin-users/admin-users.expert-sync.spec.ts +++ b/apps/api/src/modules/admin-users/admin-users.expert-sync.spec.ts @@ -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: 'تهران' }); diff --git a/apps/api/src/modules/admin-users/admin-users.service.ts b/apps/api/src/modules/admin-users/admin-users.service.ts index 197f0aa..400bd10 100644 --- a/apps/api/src/modules/admin-users/admin-users.service.ts +++ b/apps/api/src/modules/admin-users/admin-users.service.ts @@ -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; } 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 f4ce39d..f3a183e 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.service.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -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('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.'); }