import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } 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 { FreightJwtGuard } from '../../common/freight-jwt.guard'; import type { Response } from 'express'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { resolveFilterOptions } from './export-filter.util'; import { EXPORT_MIME, formatRowCap, pickByKey, resolveExportFormat, resolveRowLimit, } from './export-request.util'; import { ExportRunnerService } from './export-runner.service'; import { DATASETS, getDataset } from './export.registry'; import { ExportCatalogEntry, ExportDataset, ExportField } from './export.types'; import { CSV_ROW_CAP, PDF_ROW_CAP, TabularExportService, XLSX_ROW_CAP } from './tabular-export.service'; /** Raw query bag — filter keys are per-dataset, so DTO whitelisting can't police it. */ type RawExportQuery = Record; const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP }; /** * Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of * the schema — they never leave the server. */ const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({ key: dataset.key, title: dataset.title, description: dataset.description, group: dataset.group, groups: dataset.groups, fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({ key, label, type, group, default: isDefault, })), filters: dataset.filters, formats: ['csv', 'xlsx', 'pdf'], caps: CAPS, defaultSort: dataset.defaultSort, }); /** * Generic table export. One dataset per major table, each describing far more * fields than its list page shows — including related-entity detail. */ @ApiTags('Exports') @ApiBearerAuth() @Controller('exports') @UseGuards(FreightJwtGuard) export class ExportsController { constructor( private readonly runner: ExportRunnerService, private readonly writer: TabularExportService, private readonly userTradeAccessService: UserTradeAccessService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @Get() @ApiOperation({ summary: 'List datasets the caller has permission to export' }) async catalog(@CurrentUser() user: TCurrentUser): Promise { const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission)); return Promise.all( allowed.map(async (d) => ({ ...toCatalogEntry(d), filters: await resolveFilterOptions(d.filters, this.dataSource), })), ); } @Get(':key/count') @ApiOperation({ summary: 'Exact row count for the given filters, plus the per-format caps' }) async count( @Param('key') key: string, @Query() query: RawExportQuery, @CurrentUser() user: TCurrentUser, ): Promise<{ total: number; caps: typeof CAPS }> { const dataset = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const total = await this.runner.count(dataset, query, directions); return { total, caps: CAPS }; } @Get(':key/download') @ApiOperation({ summary: 'Export a dataset to csv, xlsx or pdf' }) async download( @Param('key') key: string, @Query() query: RawExportQuery & { format?: string; fields?: string; limit?: string }, @CurrentUser() user: TCurrentUser, @Res() res: Response, ): Promise { const dataset = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); const fields = this.resolveFields(dataset, query.fields); const rows = await this.runner.run(dataset, fields, query, directions, { cap: formatRowCap(format), limit: resolveRowLimit(format, query.limit), }); const doc = { title: dataset.title, description: dataset.description, label: `export:${dataset.key}`, columns: fields.map(({ key: k, label, type }) => ({ key: k, label, type })), rows, }; const buffer = format === 'pdf' ? await this.writer.toPdf(doc) : format === 'csv' ? await this.writer.toCsv(doc) : await this.writer.toXlsx(doc); const mime = EXPORT_MIME[format]; const stamp = new Date().toISOString().slice(0, 10); res.setHeader('Content-Disposition', `attachment; filename="${dataset.key}-${stamp}.${mime.ext}"`); res.setHeader('Content-Type', mime.type); res.send(buffer); } /** * Requested fields, whitelisted against the dataset. No `fields=` means the * DEFAULT set, not everything — a booking export has ~70 fields and dumping * all of them on an unparameterised call is nobody's intent. */ private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] { if (raw?.trim()) { const picked = pickByKey(dataset.fields, raw); // pickByKey falls back to everything when nothing matched; for a dataset // the safer read of "all keys unknown" is still the default set. if (picked.length !== dataset.fields.length) return picked; } const defaults = dataset.fields.filter((f) => f.default); return defaults.length ? defaults : dataset.fields; } private resolve(key: string, user: TCurrentUser): ExportDataset { const dataset = getDataset(key); if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`); // Export rides the dataset's own list-page view permission: if you may see // these rows, you may export them. assertFreightPermission(user, dataset.permission); return dataset; } }