2 Commits
Author SHA1 Message Date
s.esmaeiliandClaude Opus 5.5 ab39f6abed chore(api): temporary boot-time data diagnostics in the container log
Prints aggregate counts only, prefixed [DIAG], right after migrations: customers
by kind/active/archived/step, what the omde list can show and how many rows are
sales-expert trading accounts (created vs adopted), experts' profile/trading
state, referral counts, the Admin table and the last applied migrations.

Read-only, no mobile/name/id is printed, each query isolated so a failure is
reported without stopping boot. Disable with DIAG_LOG=false; remove once the
empty-omde-list investigation is closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:02:57 +03:30
s.esmaeiliandClaude Opus 5.5 ee43d664d5 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>
2026-10-26 08:00:01 +03:30
5 changed files with 440 additions and 18 deletions
+160 -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}`.
*
@@ -163868,6 +163921,102 @@ var require_app_module = __commonJS({
}
});
// dist/prisma/diagnostics.js
var require_diagnostics = __commonJS({
"dist/prisma/diagnostics.js"(exports2) {
"use strict";
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.logDataDiagnostics = logDataDiagnostics;
var common_12 = require_common();
var QUERIES = [
{
label: "migrations: last 5 applied",
sql: `SELECT name, applied_at FROM "_goldbank_migrations" ORDER BY applied_at DESC, name DESC LIMIT 5`
},
{
label: "customers by kind / active / archived / step",
sql: `SELECT kind, "isActive" AS active, "isArchived" AS archived, "verificationStep" AS step, count(*)::int AS n
FROM "Customer" GROUP BY 1,2,3,4 ORDER BY n DESC`
},
{
label: 'omde list ("all" scope) \u2014 what the panel can show',
sql: `SELECT
count(*)::int AS app_shopkeeper_not_archived,
count(*) FILTER (WHERE p.id IS NOT NULL)::int AS of_which_expert_trading_accounts,
count(*) FILTER (WHERE p.id IS NULL)::int AS listed_in_omde
FROM "Customer" c
LEFT JOIN "SalesExpertProfile" p ON p."tradingCustomerId" = c.id
WHERE c.kind = 'APP_SHOPKEEPER' AND c."isArchived" = false`
},
{
label: "expert trading accounts: created for the expert vs adopted existing customer",
sql: `SELECT
count(*)::int AS trading_accounts,
count(*) FILTER (WHERE c."createdAt" < a."createdAt")::int AS customer_older_than_expert,
count(*) FILTER (WHERE c."createdAt" >= a."createdAt")::int AS customer_newer_than_expert,
count(*) FILTER (WHERE c.mobile = a.mobile)::int AS same_mobile,
count(*) FILTER (WHERE c."referredByAgentId" IS NOT NULL)::int AS has_referrer,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Order" o WHERE o."customerId" = c.id))::int AS has_orders,
count(*) FILTER (WHERE c."firstName" IS DISTINCT FROM a."firstName"
OR c."lastName" IS DISTINCT FROM a."lastName")::int AS name_differs_from_expert
FROM "SalesExpertProfile" p
JOIN "Customer" c ON c.id = p."tradingCustomerId"
JOIN "AdminUser" a ON a.id = p."adminUserId"`
},
{
label: "AdminUser by role / active (experts + pre-split admin copies)",
sql: `SELECT role, "isActive" AS active, count(*)::int AS n FROM "AdminUser" GROUP BY 1,2 ORDER BY n DESC`
},
{
label: "experts: profile / trading account",
sql: `SELECT
count(*)::int AS experts,
count(*) FILTER (WHERE p.id IS NULL)::int AS no_profile,
count(*) FILTER (WHERE p."tradingCustomerId" IS NOT NULL)::int AS with_trading_account,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Customer" c WHERE c.mobile = a.mobile))::int AS mobile_also_a_customer
FROM "AdminUser" a
LEFT JOIN "SalesExpertProfile" p ON p."adminUserId" = a.id
WHERE a.role = 'SALES_EXPERT'`
},
{
label: "experts: when created (by day, last 10 days with rows)",
sql: `SELECT date_trunc('day', "createdAt")::date AS day, count(*)::int AS n
FROM "AdminUser" WHERE role = 'SALES_EXPERT' GROUP BY 1 ORDER BY 1 DESC LIMIT 10`
},
{
label: "customers: when created (by day, last 10 days with rows)",
sql: `SELECT date_trunc('day', "createdAt")::date AS day, count(*)::int AS n
FROM "Customer" GROUP BY 1 ORDER BY 1 DESC LIMIT 10`
},
{
label: "customers with a referring expert",
sql: `SELECT count(*) FILTER (WHERE "referredByAgentId" IS NOT NULL)::int AS referred,
count(*)::int AS all_customers
FROM "Customer"`
},
{
label: "Admin table",
sql: `SELECT role, "isActive" AS active, count(*)::int AS n FROM "Admin" GROUP BY 1,2 ORDER BY n DESC`
}
];
async function logDataDiagnostics(prisma) {
if (process.env.DIAG_LOG === "false")
return;
const logger = new common_12.Logger("DIAG");
logger.log("[DIAG] ===== data diagnostics start (counts only, read-only) =====");
for (const q of QUERIES) {
try {
const rows = await prisma.$queryRawUnsafe(q.sql);
logger.log(`[DIAG] ${q.label}: ${JSON.stringify(rows, (_k, v) => typeof v === "bigint" ? Number(v) : v)}`);
} catch (error) {
logger.warn(`[DIAG] ${q.label}: FAILED \u2014 ${error.message.split("\n").pop()}`);
}
}
logger.log("[DIAG] ===== data diagnostics end =====");
}
}
});
// dist/prisma/migrate.js
var require_migrate = __commonJS({
"dist/prisma/migrate.js"(exports2) {
@@ -164046,6 +164195,7 @@ var require_bootstrap = __commonJS({
var util_1 = require("util");
var common_12 = require_common();
var client_1 = require("@prisma/client");
var diagnostics_1 = require_diagnostics();
var migrate_1 = require_migrate();
var execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
function resolveSeedScript() {
@@ -164068,6 +164218,7 @@ var require_bootstrap = __commonJS({
} else {
logger.log("No pending migrations");
}
await (0, diagnostics_1.logDataDiagnostics)(prisma);
if (process.env.RUN_SEED === "false")
return;
const seedScript = resolveSeedScript();
@@ -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}`.
*
+4
View File
@@ -4,6 +4,7 @@ import { join } from 'path';
import { promisify } from 'util';
import { Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { logDataDiagnostics } from './diagnostics';
import { runMigrations, runOnce } from './migrate';
const execFileAsync = promisify(execFile);
@@ -40,6 +41,9 @@ export async function prepareDatabase(): Promise<void> {
logger.log('No pending migrations');
}
// TEMPORARY — see diagnostics.ts. Never throws.
await logDataDiagnostics(prisma);
if (process.env.RUN_SEED === 'false') return;
const seedScript = resolveSeedScript();
+105
View File
@@ -0,0 +1,105 @@
import { Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
/**
* TEMPORARY boot-time data diagnostics — prints aggregate COUNTS only to the
* container log, prefixed "[DIAG]", so the production state behind two open
* issues can be read from Dokploy's log without database access:
*
* 1. «لیست مشتریان عمده» is empty after the last deploy;
* 2. a sales expert's app data vs the panel.
*
* Read-only (SELECT only). No mobile, name or id is printed. Every query is
* isolated, so one failing query (e.g. a missing table) is reported and the
* rest still run; nothing here can stop the API from booting.
*
* Disable with DIAG_LOG=false. Remove this file and its call in bootstrap.ts
* once the issues are closed.
*/
const QUERIES: { label: string; sql: string }[] = [
{
label: 'migrations: last 5 applied',
sql: `SELECT name, applied_at FROM "_goldbank_migrations" ORDER BY applied_at DESC, name DESC LIMIT 5`,
},
{
label: 'customers by kind / active / archived / step',
sql: `SELECT kind, "isActive" AS active, "isArchived" AS archived, "verificationStep" AS step, count(*)::int AS n
FROM "Customer" GROUP BY 1,2,3,4 ORDER BY n DESC`,
},
{
label: 'omde list ("all" scope) — what the panel can show',
sql: `SELECT
count(*)::int AS app_shopkeeper_not_archived,
count(*) FILTER (WHERE p.id IS NOT NULL)::int AS of_which_expert_trading_accounts,
count(*) FILTER (WHERE p.id IS NULL)::int AS listed_in_omde
FROM "Customer" c
LEFT JOIN "SalesExpertProfile" p ON p."tradingCustomerId" = c.id
WHERE c.kind = 'APP_SHOPKEEPER' AND c."isArchived" = false`,
},
{
label: 'expert trading accounts: created for the expert vs adopted existing customer',
sql: `SELECT
count(*)::int AS trading_accounts,
count(*) FILTER (WHERE c."createdAt" < a."createdAt")::int AS customer_older_than_expert,
count(*) FILTER (WHERE c."createdAt" >= a."createdAt")::int AS customer_newer_than_expert,
count(*) FILTER (WHERE c.mobile = a.mobile)::int AS same_mobile,
count(*) FILTER (WHERE c."referredByAgentId" IS NOT NULL)::int AS has_referrer,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Order" o WHERE o."customerId" = c.id))::int AS has_orders,
count(*) FILTER (WHERE c."firstName" IS DISTINCT FROM a."firstName"
OR c."lastName" IS DISTINCT FROM a."lastName")::int AS name_differs_from_expert
FROM "SalesExpertProfile" p
JOIN "Customer" c ON c.id = p."tradingCustomerId"
JOIN "AdminUser" a ON a.id = p."adminUserId"`,
},
{
label: 'AdminUser by role / active (experts + pre-split admin copies)',
sql: `SELECT role, "isActive" AS active, count(*)::int AS n FROM "AdminUser" GROUP BY 1,2 ORDER BY n DESC`,
},
{
label: 'experts: profile / trading account',
sql: `SELECT
count(*)::int AS experts,
count(*) FILTER (WHERE p.id IS NULL)::int AS no_profile,
count(*) FILTER (WHERE p."tradingCustomerId" IS NOT NULL)::int AS with_trading_account,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Customer" c WHERE c.mobile = a.mobile))::int AS mobile_also_a_customer
FROM "AdminUser" a
LEFT JOIN "SalesExpertProfile" p ON p."adminUserId" = a.id
WHERE a.role = 'SALES_EXPERT'`,
},
{
label: 'experts: when created (by day, last 10 days with rows)',
sql: `SELECT date_trunc('day', "createdAt")::date AS day, count(*)::int AS n
FROM "AdminUser" WHERE role = 'SALES_EXPERT' GROUP BY 1 ORDER BY 1 DESC LIMIT 10`,
},
{
label: 'customers: when created (by day, last 10 days with rows)',
sql: `SELECT date_trunc('day', "createdAt")::date AS day, count(*)::int AS n
FROM "Customer" GROUP BY 1 ORDER BY 1 DESC LIMIT 10`,
},
{
label: 'customers with a referring expert',
sql: `SELECT count(*) FILTER (WHERE "referredByAgentId" IS NOT NULL)::int AS referred,
count(*)::int AS all_customers
FROM "Customer"`,
},
{
label: 'Admin table',
sql: `SELECT role, "isActive" AS active, count(*)::int AS n FROM "Admin" GROUP BY 1,2 ORDER BY n DESC`,
},
];
export async function logDataDiagnostics(prisma: PrismaClient): Promise<void> {
if (process.env.DIAG_LOG === 'false') return;
const logger = new Logger('DIAG');
logger.log('[DIAG] ===== data diagnostics start (counts only, read-only) =====');
for (const q of QUERIES) {
try {
const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(q.sql);
logger.log(`[DIAG] ${q.label}: ${JSON.stringify(rows, (_k, v) => (typeof v === 'bigint' ? Number(v) : v))}`);
} catch (error) {
logger.warn(`[DIAG] ${q.label}: FAILED — ${(error as Error).message.split('\n').pop()}`);
}
}
logger.log('[DIAG] ===== data diagnostics end =====');
}