diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts index a840f54a0..d36bbecd6 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -122,6 +122,16 @@ export const customersDataset: ExportDataset = { { value: 'ethiopian', label: 'Ethiopian' }, { value: 'foreign', label: 'Foreign' }, ] }, + // The operational role, NOT `type` above — the list's Role pill. One + // `customer` company routinely holds several profiles, so this asks "who + // does X?" rather than "what kind of company is this?". + { key: 'profileType', label: 'Role', type: 'select', options: [ + { value: 'importer', label: 'Importer' }, + { value: 'exporter', label: 'Exporter' }, + { value: 'freight_forwarder', label: 'Freight forwarder' }, + { value: 'dj_freight_forwarder', label: 'DJ freight forwarder' }, + { value: 'transporter', label: 'Transporter' }, + ] }, // The list's Status filter folds the review queues in, and sends these two // alongside `status`. They are predicates, not columns — see // `company-scope.sql.ts`, shared with the list so both agree exactly. @@ -147,6 +157,18 @@ export const customersDataset: ExportDataset = { if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind }); if (params.status) qb.andWhere('c.status = :status', { status: params.status }); if (params.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality }); + if (params.profileType) { + // EXISTS, matching the list repository exactly — a join here would + // multiply a company holding two profiles into two rows and put the file + // out of step with the count endpoint. + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.company_profiles cp_type + WHERE cp_type.company_id = c.id + AND cp_type.deleted_at IS NULL + AND cp_type.type = :profileType)`, + { profileType: params.profileType }, + ); + } if (params.onboardingCompleted) { const draft = companyDraftSql('c'); qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft); diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts index 1b473b12a..58724298c 100644 --- a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts @@ -2,6 +2,7 @@ import { EXPORT_MIME, formatRowCap, pickByKey, + pickDatasetFields, resolveExportFormat, resolveRowLimit, } from './export-request.util'; @@ -85,3 +86,48 @@ describe('pickByKey', () => { expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns); }); }); + +describe('pickDatasetFields', () => { + const fields = [ + { key: 'name', label: 'Company', default: true }, + { key: 'tin', label: 'TIN', default: true }, + { key: 'website', label: 'Website' }, + { key: 'kebele', label: 'Kebele' }, + ]; + const defaults = [fields[0], fields[1]]; + + it.each([undefined, '', ' ', ','])('%p means the default fields', (raw) => { + expect(pickDatasetFields(fields, raw)).toEqual(defaults); + }); + + it('a subset is honoured, in the dataset\'s own order', () => { + expect(pickDatasetFields(fields, 'kebele,name')).toEqual([fields[0], fields[3]]); + }); + + it('asking for EVERY field exports every field', () => { + // The dialog's "All columns" chip sends exactly this. Falling back to the + // defaults here was the bug: 37 ticked customer columns exported as 9. + expect(pickDatasetFields(fields, 'name,tin,website,kebele')).toEqual(fields); + }); + + it('a non-default field alone is not widened back to the defaults', () => { + expect(pickDatasetFields(fields, 'website')).toEqual([fields[2]]); + }); + + it('unknown keys are dropped, the recognised ones still stand', () => { + expect(pickDatasetFields(fields, 'ghost,website')).toEqual([fields[2]]); + }); + + it('all-unknown keys fall back to the defaults, not to everything', () => { + expect(pickDatasetFields(fields, 'ghost,also-ghost')).toEqual(defaults); + }); + + it('surrounding whitespace in a hand-built fields list is tolerated', () => { + expect(pickDatasetFields(fields, ' name , website ')).toEqual([fields[0], fields[2]]); + }); + + it('a dataset with no default flags falls back to every field', () => { + const flat = [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }]; + expect(pickDatasetFields(flat, undefined)).toEqual(flat); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.ts index 39f1f2e8c..5b57c82f0 100644 --- a/apps/edr-freight-api/src/modules/exports/export-request.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.ts @@ -56,3 +56,31 @@ export function pickByKey(all: T[], raw: string | und const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all; return filtered.length ? filtered : all; } + +/** + * A dataset's requested field subset, whitelisted against what the caller may + * have. Unlike `pickByKey`, "nothing recognised" falls back to the DEFAULT + * fields rather than to every field — a bookings export declares ~70 columns + * and dumping all of them on an unparameterised call is nobody's intent. + * + * Selecting every field is a legitimate request — the dialog's "All columns" + * chip sends exactly that — so the fallback keys off whether any requested key + * MATCHED, never off how many fields came back. Comparing the picked count to + * `all.length` (as this did originally) made "All columns" silently export the + * default columns instead. + */ +export function pickDatasetFields( + all: T[], + raw: string | undefined, +): T[] { + const requested = new Set( + raw + ?.split(',') + .map((k) => k.trim()) + .filter(Boolean) ?? [], + ); + const picked = requested.size ? all.filter((f) => requested.has(f.key)) : []; + if (picked.length) return picked; + const defaults = all.filter((f) => f.default); + return defaults.length ? defaults : all; +} diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts index 46c6ebad2..808e375bc 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -13,7 +13,7 @@ import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util import { EXPORT_MIME, formatRowCap, - pickByKey, + pickDatasetFields, resolveExportFormat, resolveRowLimit, } from './export-request.util'; @@ -105,7 +105,7 @@ export class ExportsController { const dataset = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); - const fields = ExportsController.pickFields( + const fields = pickDatasetFields( await resolveDatasetFields(dataset, this.dataSource), query.fields, ); @@ -135,22 +135,6 @@ export class ExportsController { 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 static pickFields(all: ExportField[], raw: string | undefined): ExportField[] { - if (raw?.trim()) { - const picked = pickByKey(all, 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 !== all.length) return picked; - } - const defaults = all.filter((f) => f.default); - return defaults.length ? defaults : all; - } - private resolve(key: string, user: TCurrentUser): ExportDataset { const dataset = getDataset(key); if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`);