diff --git a/apps/api/release/app.js b/apps/api/release/app.js index 48ab618..4fd4b43 100644 --- a/apps/api/release/app.js +++ b/apps/api/release/app.js @@ -101621,17 +101621,70 @@ var require_admin_users_service = __commonJS({ } }; } - return this.prisma.adminUser.update({ - where: { id }, - data: patch, - include: { - userType: true, - branch: true, - avatarFile: true, - salesExpertProfile: { include: { city: true } } - } + return this.prisma.$transaction(async (tx) => { + const updated = await tx.adminUser.update({ + where: { id }, + data: patch, + include: { + userType: true, + branch: true, + avatarFile: true, + salesExpertProfile: { include: { city: true } } + } + }); + await this.syncTradingAccount(tx, updated, existing.mobile, { + name: patch.firstName !== void 0 || patch.lastName !== void 0 || patch.fullName !== void 0, + mobile: patch.mobile !== void 0, + gramLevel: data.gram_level !== void 0, + percent: data.percent !== void 0 + }); + return updated; }); } + /** + * Mirror an expert edit onto their trading Customer row (D21). + * + * The apps show an expert their OWN account through that row (/customer/me), + * which is seeded from the expert once, at first sign-in, and was never + * updated again — so a name or credit-limit change made here never reached + * the app. In the reference the agent and that account were ONE row + * (customers.type=1), so an edit changed both; this restores that. + * + * Only the fields the operator actually changed are written. The mobile is + * copied only when no other customer holds it (the column is unique); + * otherwise it is left and logged rather than failing the expert's save. + */ + async syncTradingAccount(tx, expert, previousMobile, changed) { + const tradingId = expert.salesExpertProfile?.tradingCustomerId; + if (!tradingId) + return; + const data = {}; + if (changed.name) { + if (expert.firstName !== null || expert.lastName !== null) { + data.firstName = expert.firstName; + data.lastName = expert.lastName; + } else { + data.fullName = expert.fullName; + } + } + if (changed.mobile && expert.mobile !== previousMobile) { + const holder = await tx.customer.findUnique({ where: { mobile: expert.mobile }, select: { id: true } }); + if (!holder) { + data.mobile = expert.mobile; + } else if (holder.id !== tradingId) { + this.logger.warn(`Expert ${expert.id}: new mobile ${expert.mobile} already belongs to customer ${holder.id}; trading account ${tradingId} keeps its old mobile.`); + } + } + if (changed.gramLevel && expert.salesExpertProfile) { + data.goldCreditLimitGrams = expert.salesExpertProfile.gramLevel; + } + if (changed.percent) { + data.makingChargeOffsetPercent = expert.wagePercent ?? new client_1.Prisma.Decimal(0); + } + if (Object.keys(data).length > 0) { + await tx.customer.update({ where: { id: tradingId }, data }); + } + } /** * Legacy `DELETE users/{id}`. * 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 new file mode 100644 index 0000000..dca5fb6 --- /dev/null +++ b/apps/api/src/modules/admin-users/admin-users.expert-sync.spec.ts @@ -0,0 +1,91 @@ +import { Prisma } from '@prisma/client'; +import { AdminUsersService } from './admin-users.service'; + +/** + * Editing a sales expert in the panel must reach the app: the app shows the + * expert their own account through the linked trading Customer row, which is + * only seeded once at first sign-in. + */ +describe('AdminUsersService — expert edit reaches the trading account', () => { + const expertRow = { + id: 'exp1', + role: 'SALES_EXPERT', + fullName: 'سعید اسماعیلی', + firstName: 'سعید', + lastName: 'اسماعیلی', + mobile: '09902560722', + wagePercent: new Prisma.Decimal(12), + isActive: true, + salesExpertProfile: { tradingCustomerId: 'cust1', gramLevel: new Prisma.Decimal(0) }, + }; + + function build(customerHolder: { id: string } | null = null) { + const customerUpdate = jest.fn(async () => ({})); + const tx = { + adminUser: { + update: jest.fn(async ({ data }: { data: Record }) => ({ + ...expertRow, + ...(data.firstName !== undefined ? { firstName: data.firstName, fullName: `${data.firstName} ${data.lastName}` } : {}), + ...(data.lastName !== undefined ? { lastName: data.lastName } : {}), + ...(data.mobile !== undefined ? { mobile: data.mobile } : {}), + ...(data.wagePercent !== undefined ? { wagePercent: data.wagePercent } : {}), + salesExpertProfile: { + ...expertRow.salesExpertProfile, + ...((data.salesExpertProfile as { upsert?: { update?: { gramLevel?: Prisma.Decimal } } } | undefined) + ?.upsert?.update?.gramLevel !== undefined + ? { gramLevel: (data.salesExpertProfile as { upsert: { update: { gramLevel: Prisma.Decimal } } }).upsert.update.gramLevel } + : {}), + }, + })), + }, + customer: { update: customerUpdate, findUnique: jest.fn(async () => customerHolder) }, + }; + const prisma = { + ...tx, + admin: { findUnique: jest.fn(async () => null) }, + adminUser: { + ...tx.adminUser, + findFirst: jest.fn(async () => expertRow), + // the expert-table mobile clash check: no other expert holds the number + findUnique: jest.fn(async () => null), + }, + $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), + }; + const service = new AdminUsersService(prisma as never, {} as never, {} as never); + return { service, customerUpdate }; + } + + it('copies a name change to the trading account', async () => { + const { service, customerUpdate } = build(); + await service.update('exp1', { fname: 'سعید1', lname: 'اسماعیلی1' }); + expect(customerUpdate).toHaveBeenCalledWith({ + where: { id: 'cust1' }, + data: { firstName: 'سعید1', lastName: 'اسماعیلی1' }, + }); + }); + + it('copies the credit limit and wage percent the operator changed', async () => { + const { service, customerUpdate } = build(); + await service.update('exp1', { gram_level: '50', percent: '3' }); + const data = (customerUpdate.mock.calls[0] as unknown as [{ data: Record }])[0].data; + expect(data.goldCreditLimitGrams.toString()).toBe('50'); + expect(data.makingChargeOffsetPercent.toString()).toBe('3'); + expect(data).not.toHaveProperty('firstName'); + }); + + it('copies a new mobile only when no other customer holds it', async () => { + const free = build(null); + await free.service.update('exp1', { mobile: '09120001111' }); + expect(free.customerUpdate).toHaveBeenCalledWith({ where: { id: 'cust1' }, data: { mobile: '09120001111' } }); + + const taken = build({ id: 'someone-else' }); + await taken.service.update('exp1', { mobile: '09120001111' }); + expect(taken.customerUpdate).not.toHaveBeenCalled(); + }); + + it('writes nothing to the trading account when nothing it mirrors changed', async () => { + const { service, customerUpdate } = build(); + await service.update('exp1', { address: 'تهران' }); + expect(customerUpdate).not.toHaveBeenCalled(); + }); +}); 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 aaaf4db..197f0aa 100644 --- a/apps/api/src/modules/admin-users/admin-users.service.ts +++ b/apps/api/src/modules/admin-users/admin-users.service.ts @@ -519,18 +519,89 @@ export class AdminUsersService { // Experts sign in to the apps, whose sessions are not admin-panel sessions, // so there is nothing to revoke here. - return this.prisma.adminUser.update({ - where: { id }, - data: patch, - include: { - userType: true, - branch: true, - avatarFile: true, - salesExpertProfile: { include: { city: true } }, - }, + return this.prisma.$transaction(async (tx) => { + const updated = await tx.adminUser.update({ + where: { id }, + data: patch, + include: { + userType: true, + branch: true, + avatarFile: true, + salesExpertProfile: { include: { city: true } }, + }, + }); + await this.syncTradingAccount(tx, updated, existing.mobile, { + name: patch.firstName !== undefined || patch.lastName !== undefined || patch.fullName !== undefined, + mobile: patch.mobile !== undefined, + gramLevel: data.gram_level !== undefined, + percent: data.percent !== undefined, + }); + return updated; }); } + /** + * Mirror an expert edit onto their trading Customer row (D21). + * + * The apps show an expert their OWN account through that row (/customer/me), + * which is seeded from the expert once, at first sign-in, and was never + * updated again — so a name or credit-limit change made here never reached + * the app. In the reference the agent and that account were ONE row + * (customers.type=1), so an edit changed both; this restores that. + * + * Only the fields the operator actually changed are written. The mobile is + * copied only when no other customer holds it (the column is unique); + * otherwise it is left and logged rather than failing the expert's save. + */ + private async syncTradingAccount( + tx: Prisma.TransactionClient, + expert: { + id: string; + fullName: string; + firstName: string | null; + lastName: string | null; + mobile: string; + wagePercent: Prisma.Decimal | null; + salesExpertProfile: { tradingCustomerId: string | null; gramLevel: Prisma.Decimal } | null; + }, + previousMobile: string, + changed: { name: boolean; mobile: boolean; gramLevel: boolean; percent: boolean }, + ) { + const tradingId = expert.salesExpertProfile?.tradingCustomerId; + if (!tradingId) return; + + const data: Prisma.CustomerUpdateInput = {}; + if (changed.name) { + // Write the parts; the customer_sync_full_name trigger rebuilds fullName. + if (expert.firstName !== null || expert.lastName !== null) { + data.firstName = expert.firstName; + data.lastName = expert.lastName; + } else { + data.fullName = expert.fullName; + } + } + if (changed.mobile && expert.mobile !== previousMobile) { + const holder = await tx.customer.findUnique({ where: { mobile: expert.mobile }, select: { id: true } }); + if (!holder) { + data.mobile = expert.mobile; + } else if (holder.id !== tradingId) { + this.logger.warn( + `Expert ${expert.id}: new mobile ${expert.mobile} already belongs to customer ${holder.id}; trading account ${tradingId} keeps its old mobile.`, + ); + } + } + if (changed.gramLevel && expert.salesExpertProfile) { + data.goldCreditLimitGrams = expert.salesExpertProfile.gramLevel; + } + if (changed.percent) { + data.makingChargeOffsetPercent = expert.wagePercent ?? new Prisma.Decimal(0); + } + + if (Object.keys(data).length > 0) { + await tx.customer.update({ where: { id: tradingId }, data }); + } + } + /** * Legacy `DELETE users/{id}`. *