feat(customers): opt-in debug block on the omde lists, printed in the panel console

Sending X-Debug: 1 makes the five wholesale list endpoints return a counts-only
`debug` block next to the rows: the WHERE that actually ran, how many customers
the scope matches before the grid's own filters, how many customers exist per
kind/active/archived/step and how many are an expert's trading account. The
panel sends the header and console-logs the block when localStorage.debug === '1'.

Off by default; DIAG_LOG=false disables it server-side. No names or mobiles.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mahmood
2026-10-26 08:59:33 +03:30
co-authored by Claude Sonnet 5.5
parent ab39f6abed
commit 0156a976e7
4 changed files with 113 additions and 16 deletions
@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
@@ -18,45 +18,47 @@ import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
export class AdminCustomersController {
constructor(private readonly customers: AdminCustomersService) {}
private list(query: TableQueryDto, scope: OmdeScope) {
return this.customers.list(query, scope);
// `X-Debug: 1` (set by the panel when localStorage.debug === '1') adds a
// counts-only `debug` block to the response — see AdminCustomersService.listDebug.
private list(query: TableQueryDto, scope: OmdeScope, debug?: string) {
return this.customers.list(query, scope, debug === '1');
}
@Post('list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listAll(@Body() query: TableQueryDto) {
return this.list(query, 'all');
listAll(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
return this.list(query, 'all', debug);
}
@Post('banker/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('persons.list.client.omde')
listBanker(@Body() query: TableQueryDto) {
return this.list(query, 'banker');
listBanker(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
return this.list(query, 'banker', debug);
}
@Post('shopkeeper/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('persons.list.client.omde')
listShopkeeper(@Body() query: TableQueryDto) {
return this.list(query, 'shopkeeper');
listShopkeeper(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
return this.list(query, 'shopkeeper', debug);
}
@Post('non-verified/list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listNonVerified(@Body() query: TableQueryDto) {
return this.list(query, 'non_verified');
listNonVerified(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
return this.list(query, 'non_verified', debug);
}
@Post('archived/list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listArchived(@Body() query: TableQueryDto) {
return this.list(query, 'archived');
listArchived(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
return this.list(query, 'archived', debug);
}
/** Legacy POST single-customer — the retail customer list. */
@@ -77,6 +77,53 @@ describe('AdminCustomersService', () => {
});
});
describe('list() debug block', () => {
function buildList() {
const customer = {
findMany: jest.fn(async () => []),
count: jest.fn(async () => 0),
groupBy: jest.fn(async () => [
{ kind: 'APP_SHOPKEEPER', isActive: true, isArchived: false, verificationStep: 'UNVERIFIED', _count: { _all: 3 } },
]),
};
const table = {
assertNotExport: jest.fn(),
build: jest.fn(() => ({ where: {}, orderBy: { createdAt: 'desc' }, skip: 0, take: 10, page: 1, limit: 10 })),
respond: jest.fn((rows: unknown[], total: number) => ({ rows, query: { total } })),
};
const accountingConfig = { get: jest.fn(async () => ({ provider: 'none' })), inactiveReason: jest.fn() };
const service = new AdminCustomersService(
{ customer } as never,
table as never,
{} as never,
{} as never,
accountingConfig as never,
);
return { service, customer };
}
it('is absent unless asked for', async () => {
const { service } = buildList();
expect(await service.list({} as never, 'all')).not.toHaveProperty('debug');
});
it('carries counts only when asked for, and honours DIAG_LOG=false', async () => {
const { service } = buildList();
const out = (await service.list({} as never, 'all', true)) as { debug: Record<string, unknown> };
expect(out.debug).toMatchObject({ scope: 'all', customers_total: 3, returned_rows: 0 });
expect(out.debug.by_kind).toEqual([
{ kind: 'APP_SHOPKEEPER', isActive: true, isArchived: false, verificationStep: 'UNVERIFIED', n: 3 },
]);
process.env.DIAG_LOG = 'false';
try {
expect(await service.list({} as never, 'all', true)).not.toHaveProperty('debug');
} finally {
delete process.env.DIAG_LOG;
}
});
});
describe('national code and password', () => {
it('create() without a national code or password stores NULL and the OTP-only marker', async () => {
const { service, writes } = build();
@@ -296,7 +296,39 @@ export class AdminCustomersService {
}
}
async list(query: TableQueryDto, scope: OmdeScope = 'all') {
/**
* Opt-in (X-Debug header) troubleshooting block for an empty list: counts only,
* no names/mobiles. Shows the exact WHERE the request ran, how many customers
* the scope matches before the grid's own filters, and how many customers
* exist per kind / are an expert's trading account. Off with DIAG_LOG=false.
*/
private async listDebug(
scope: OmdeScope,
scoped: Prisma.CustomerWhereInput,
total: number,
returned: number,
) {
const [byKind, inScope, expertTradingAccounts] = await Promise.all([
this.prisma.customer.groupBy({
by: ['kind', 'isActive', 'isArchived', 'verificationStep'],
_count: { _all: true },
}),
this.prisma.customer.count({ where: this.scopeWhere(scope) }),
this.prisma.customer.count({ where: { salesExpertTradingFor: { isNot: null } } }),
]);
return {
scope,
where: scoped,
matched_with_grid_filters: total,
returned_rows: returned,
matched_by_scope_only: inScope,
customers_total: byKind.reduce((n, g) => n + g._count._all, 0),
expert_trading_accounts: expertTradingAccounts,
by_kind: byKind.map(({ _count, ...g }) => ({ ...g, n: _count._all })),
};
}
async list(query: TableQueryDto, scope: OmdeScope = 'all', debug = false) {
this.table.assertNotExport(query);
const aliased = this.applyAliases(query);
@@ -325,13 +357,15 @@ export class AdminCustomersService {
]);
const linked = await this.accountingLinked(rows.map((r) => r.id));
return this.table.respond(
const result = this.table.respond(
rows.map((r) => ({ ...this.toRow(r), ...AdminCustomersService.accountingCells(r, linked) })),
total,
query,
page,
limit,
);
if (!debug || process.env.DIAG_LOG === 'false') return result;
return { ...result, debug: await this.listDebug(scope, scoped, total, rows.length) };
}
/**