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
+15 -1
View File
@@ -52,6 +52,11 @@ ax.interceptors.request.use(
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
// Opt-in troubleshooting: run `localStorage.setItem('debug', '1')` in the
// console and reload; list endpoints then answer with a `debug` block.
if (localStorage.getItem('debug') === '1') {
config.headers['X-Debug'] = '1';
}
return config;
},
(error) => Promise.reject(error),
@@ -67,7 +72,16 @@ ax.interceptors.request.use(
*/
let redirecting = false;
ax.interceptors.response.use(
(response) => response,
(response) => {
const debug = response.data?.debug;
if (debug && localStorage.getItem('debug') === '1') {
console.groupCollapsed(`[DEBUG] ${response.config.url}`);
console.log(debug);
if (Array.isArray(debug.by_kind)) console.table(debug.by_kind);
console.groupEnd();
}
return response;
},
(error) => {
if (error?.response?.status === 401 && !redirecting) {
const onLoginPage = window.location.pathname.endsWith('/auth/login');
@@ -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) };
}
/**