Files
edr-platform/apps/edr-freight-api/src/modules/reports/reports.controller.ts
Nathnael ce90be5c88 fix(reports): stop the 'first N rows' option failing on large exports
The export path used one number for two different things: the format's hard
row cap, and the caller's explicit 'give me the first N rows'. Because
resolveExportCap() returned min(requested, formatCap) and runAll() then threw
when the result reached it, picking 'Records: First 100' in the export dialog
400'd on any report with more than 100 rows — the user asked to be truncated
and got an error instead.

Splits them: formatRowCap() is the hard, non-caller-controllable ceiling that
still throws when exceeded (a silently short file hides missing rows), while
resolveRowLimit() is the deliberate truncation and is honoured by slicing.
Verified against a 223-row dataset: limit=5 now returns 5 rows, and no limit
returns all 223.
2026-08-20 05:28:51 +00:00

141 lines
5.3 KiB
TypeScript

import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
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 { 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 {
EXPORT_MIME,
formatRowCap,
pickByKey,
resolveExportFormat,
resolveRowLimit,
} from '../exports/export-request.util';
import { TabularExportService } from '../exports/tabular-export.service';
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
import { REPORTS, getReport } from './report.registry';
import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types';
const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => {
const { query: _query, summary, ...meta } = def;
return { ...meta, hasSummary: Boolean(summary) };
};
/**
* Filters whose choices are reference data resolve their options here rather
* than declaring them inline, so the catalog the frontend receives looks the
* same either way. Cached for the process lifetime — these are small, rarely
* changing lists (23 stations, 18 cargo types), and the catalog is hit on
* every page load.
*/
const optionsCache = new Map<string, ReportFilterOption[]>();
async function resolveFilterOptions(
def: ReportCatalogEntry,
ds: DataSource,
): Promise<ReportCatalogEntry> {
if (!def.filters.some((f) => f.optionsQuery)) return def;
const filters = await Promise.all(
def.filters.map(async (filter) => {
if (!filter.optionsQuery) return filter;
let options = optionsCache.get(filter.key);
if (!options) {
options = await filter.optionsQuery(ds);
optionsCache.set(filter.key, options);
}
// Drop the resolver itself — it is a function and would not serialise.
const { optionsQuery: _resolver, ...rest } = filter;
return { ...rest, options };
}),
);
return { ...def, filters };
}
@ApiTags('Reports')
@ApiBearerAuth()
@Controller('reports')
@BookingStaff(FREIGHT_PERMS.reports.view)
export class ReportsController {
constructor(
private readonly runner: ReportRunnerService,
private readonly exportService: TabularExportService,
private readonly userTradeAccessService: UserTradeAccessService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@Get()
@ApiOperation({ summary: 'List reports the caller has permission to run' })
async catalog(@CurrentUser() user: TCurrentUser): Promise<ReportCatalogEntry[]> {
const allowed = REPORTS.filter((def) =>
hasFreightPermission(user, reportPermissionKey(def.key)),
).map(toCatalogEntry);
return Promise.all(allowed.map((def) => resolveFilterOptions(def, this.dataSource)));
}
@Get(':key')
@ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' })
async run(
@Param('key') key: string,
@Query() query: RawReportQuery,
@CurrentUser() user: TCurrentUser,
) {
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, csv or pdf' })
async export(
@Param('key') key: string,
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
const exportColumns = pickByKey(def.columns, query.fields);
const { items, kpis } = await this.runner.runAll(def, query, directions, {
cap: formatRowCap(format),
limit: resolveRowLimit(format, query.limit),
});
const doc = {
title: def.title,
description: def.description,
label: `report:${def.key}`,
columns: exportColumns,
rows: items,
kpis,
};
const buffer =
format === 'pdf'
? await this.exportService.toPdf(doc)
: format === 'csv'
? await this.exportService.toCsv(doc)
: await this.exportService.toXlsx(doc);
const mime = EXPORT_MIME[format];
res.setHeader('Content-Disposition', `attachment; filename="${def.key}.${mime.ext}"`);
res.setHeader('Content-Type', mime.type);
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;
}
}