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(); async function resolveFilterOptions( def: ReportCatalogEntry, ds: DataSource, ): Promise { 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 { 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 { 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; } }