mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 12:28:21 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user