mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
IAM lets an employee hold several positions, but the vendored JwtGuard collapses employee.positions[] down to a single employee.position and drops the rest. Non-delegate secondary positions vanished entirely, so staff on two posts resolved to one post's permissions and every check on the other rejected them. FreightJwtGuard re-attaches the full list from the same session snapshot the parent guard already read, so nothing extra is fetched per request beyond a cached session lookup. employee.position is left untouched, keeping audit logging and delegation unaffected. collectPermissionKeys and collectPositionTypeKeys now union across every position, and /me returns them all. Verified against a real two-position user (djibouti-gl-director + djibouti-gl-chief) on the local dev database: /me positions 1 -> 2 /me permissionKeys 17 -> 28 GET /api/interchange-documents 403 -> 200 GET /api/trains 403 -> 200 11 permissions recovered, none lost. Six single-position users return byte-identical payloads before and after.
157 lines
6.0 KiB
TypeScript
157 lines
6.0 KiB
TypeScript
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<string, string | undefined>;
|
|
|
|
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<ExportCatalogEntry[]> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|