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:
co-authored by
Claude Sonnet 5.5
parent
ab39f6abed
commit
0156a976e7
@@ -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) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user