mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 11:18:17 +00:00
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.
141 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|