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) {
|
if (config.data instanceof FormData) {
|
||||||
delete config.headers['Content-Type'];
|
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;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error),
|
(error) => Promise.reject(error),
|
||||||
@@ -67,7 +72,16 @@ ax.interceptors.request.use(
|
|||||||
*/
|
*/
|
||||||
let redirecting = false;
|
let redirecting = false;
|
||||||
ax.interceptors.response.use(
|
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) => {
|
(error) => {
|
||||||
if (error?.response?.status === 401 && !redirecting) {
|
if (error?.response?.status === 401 && !redirecting) {
|
||||||
const onLoginPage = window.location.pathname.endsWith('/auth/login');
|
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 { AdminCustomersService, type OmdeScope } from './admin-customers.service';
|
||||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||||
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
|
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||||
@@ -18,45 +18,47 @@ import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
|||||||
export class AdminCustomersController {
|
export class AdminCustomersController {
|
||||||
constructor(private readonly customers: AdminCustomersService) {}
|
constructor(private readonly customers: AdminCustomersService) {}
|
||||||
|
|
||||||
private list(query: TableQueryDto, scope: OmdeScope) {
|
// `X-Debug: 1` (set by the panel when localStorage.debug === '1') adds a
|
||||||
return this.customers.list(query, scope);
|
// 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')
|
@Post('list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePermission('persons.list.client.omde')
|
@RequirePermission('persons.list.client.omde')
|
||||||
listAll(@Body() query: TableQueryDto) {
|
listAll(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
|
||||||
return this.list(query, 'all');
|
return this.list(query, 'all', debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('banker/list')
|
@Post('banker/list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@SuperAdminOnly()
|
@SuperAdminOnly()
|
||||||
@RequirePermission('persons.list.client.omde')
|
@RequirePermission('persons.list.client.omde')
|
||||||
listBanker(@Body() query: TableQueryDto) {
|
listBanker(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
|
||||||
return this.list(query, 'banker');
|
return this.list(query, 'banker', debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('shopkeeper/list')
|
@Post('shopkeeper/list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@SuperAdminOnly()
|
@SuperAdminOnly()
|
||||||
@RequirePermission('persons.list.client.omde')
|
@RequirePermission('persons.list.client.omde')
|
||||||
listShopkeeper(@Body() query: TableQueryDto) {
|
listShopkeeper(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
|
||||||
return this.list(query, 'shopkeeper');
|
return this.list(query, 'shopkeeper', debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('non-verified/list')
|
@Post('non-verified/list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePermission('persons.list.client.omde')
|
@RequirePermission('persons.list.client.omde')
|
||||||
listNonVerified(@Body() query: TableQueryDto) {
|
listNonVerified(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
|
||||||
return this.list(query, 'non_verified');
|
return this.list(query, 'non_verified', debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('archived/list')
|
@Post('archived/list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePermission('persons.list.client.omde')
|
@RequirePermission('persons.list.client.omde')
|
||||||
listArchived(@Body() query: TableQueryDto) {
|
listArchived(@Body() query: TableQueryDto, @Headers('x-debug') debug?: string) {
|
||||||
return this.list(query, 'archived');
|
return this.list(query, 'archived', debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy POST single-customer — the retail customer list. */
|
/** 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', () => {
|
describe('national code and password', () => {
|
||||||
it('create() without a national code or password stores NULL and the OTP-only marker', async () => {
|
it('create() without a national code or password stores NULL and the OTP-only marker', async () => {
|
||||||
const { service, writes } = build();
|
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);
|
this.table.assertNotExport(query);
|
||||||
const aliased = this.applyAliases(query);
|
const aliased = this.applyAliases(query);
|
||||||
|
|
||||||
@@ -325,13 +357,15 @@ export class AdminCustomersService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const linked = await this.accountingLinked(rows.map((r) => r.id));
|
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) })),
|
rows.map((r) => ({ ...this.toRow(r), ...AdminCustomersService.accountingCells(r, linked) })),
|
||||||
total,
|
total,
|
||||||
query,
|
query,
|
||||||
page,
|
page,
|
||||||
limit,
|
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