feat(freight-api): replace canned reports with a generic report engine

Nuke the 17 hand-written raw-SQL reports (no pagination, hard LIMITs) and
the reports module built around them. Replace with a resolver contract:
a report declares columns/filters/permission and a TypeORM QueryBuilder;
ReportRunnerService applies filtering, a whitelisted sort, offset/limit
paging, and a COUNT(*) FROM (query) wrapper for the total (getCount() is
wrong for GROUP BY). ReportExportService re-runs the same resolver
unpaginated for xlsx (exceljs) and pdf (existing PdfRenderService, now
landscape-capable) exports.

Ships with 4 reports: bookings-list, revenue-by-customer,
aging-receivables, contract-utilization. Catalog + per-report permission
checks live in the controller; adding a report is one new definitions/
file plus a REPORT_KEYS entry, no frontend change.
This commit is contained in:
Nathnael
2026-08-13 07:52:20 +00:00
parent 08804c84e9
commit b7583df426
19 changed files with 976 additions and 1056 deletions

View File

@@ -1,34 +1,90 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { Response } from 'express';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { ReportQueryDto } from './dto/report-query.dto';
import { ReportResultDto } from './dto/report-result.dto';
import { ReportsService } from './reports.service';
import { PDF_ROW_CAP, ReportExportService, XLSX_ROW_CAP } from './report-export.service';
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
import { REPORTS, getReport } from './report.registry';
import { ReportCatalogEntry, ReportDefinition } from './report.types';
const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => {
const { query: _query, summary, ...meta } = def;
return { ...meta, hasSummary: Boolean(summary) };
};
@ApiTags('Reports')
@ApiBearerAuth()
@Controller('reports')
@BookingStaff(FREIGHT_PERMS.reports.view)
export class ReportsController {
constructor(
private readonly reportsService: ReportsService,
private readonly runner: ReportRunnerService,
private readonly exportService: ReportExportService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Get()
@ApiOperation({ summary: 'List reports the caller has permission to run' })
async catalog(@CurrentUser() user: TCurrentUser): Promise<ReportCatalogEntry[]> {
return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map(
toCatalogEntry,
);
}
@Get(':key')
@BookingStaff(FREIGHT_PERMS.reports.view)
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
@ApiOkResponse({ type: ReportResultDto })
@ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' })
async run(
@Param('key') key: string,
@Query() query: ReportQueryDto,
@Query() query: RawReportQuery,
@CurrentUser() user: TCurrentUser,
): Promise<ReportResultDto> {
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
return this.reportsService.run(key, query, allowed);
) {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
return this.runner.run(def, query, directions);
}
@Get(':key/export')
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
async export(
@Param('key') key: string,
@Query() query: RawReportQuery & { format?: string },
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
const cap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
const buffer =
format === 'pdf'
? await this.exportService.toPdf(def, items, kpis)
: await this.exportService.toXlsx(def, items, kpis);
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader(
'Content-Type',
format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.send(buffer);
}
private resolve(key: string, user: TCurrentUser): ReportDefinition {
const def = getReport(key);
if (!def) throw new NotFoundException(`Unknown report: ${key}`);
// Exact-match on purpose — unlike FreightPermissionGuard's :view/:read
// fallback, a report's own key is the only thing that opens it.
assertFreightPermission(user, reportPermissionKey(def.key));
return def;
}
}