fix(experts): an expert edited in the panel now shows up in the app

The app shows a sales expert their own account through the linked trading
Customer row (/customer/me). That row was seeded from the expert once, at first
sign-in, and never updated again, so a name, mobile, credit-limit or wage-percent
change made in the panel never reached the app. In the reference the agent and
that account were one row, so an edit changed both.

Editing an expert now mirrors exactly the changed fields onto the trading row in
the same transaction: name parts, credit limit (gram_level), wage percent, and the
mobile when no other customer holds it. Existing drift heals on the next save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-26 08:00:01 +03:30
co-authored by Claude Opus 5.5
parent 600ce06d15
commit ee43d664d5
3 changed files with 233 additions and 18 deletions
+62 -9
View File
@@ -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}`.
*
@@ -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<string, unknown> }) => ({
...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<string, Prisma.Decimal> }])[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();
});
});
@@ -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}`.
*