diff --git a/apps/admin-panel/src/api/client.ts b/apps/admin-panel/src/api/client.ts index 4d9e069..df75a57 100644 --- a/apps/admin-panel/src/api/client.ts +++ b/apps/admin-panel/src/api/client.ts @@ -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'); diff --git a/apps/api/src/modules/admin-customers/admin-customers.controller.ts b/apps/api/src/modules/admin-customers/admin-customers.controller.ts index e1341ad..beddf13 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.controller.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.controller.ts @@ -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. */ diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts b/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts index 8bf5b1d..3671ebe 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.spec.ts @@ -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 }; + 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(); diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.ts b/apps/api/src/modules/admin-customers/admin-customers.service.ts index 2b806aa..9bc089a 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts @@ -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) }; } /**