diff --git a/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts b/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts new file mode 100644 index 000000000..a98fce97c --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts @@ -0,0 +1,57 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; + +import { FilterBookingDto } from '../../modules/bookings/dto/filter-booking.dto'; +import { ListTrainSchedulesQueryDto } from '../../modules/train-scheduling/dto/list-train-schedules-query.dto'; + +/** + * The route filters carry one id, `a,b`, or a repeated param, and the + * repositories then branch on `?.length` before emitting `IN (:...ids)`. + * Two things have to hold or that breaks at runtime, not compile time: + * the value must always arrive as an array (a bare string would make + * `.length` count characters), and an absent/blank param must arrive as + * `undefined`, never `[]` — TypeORM turns `[]` into the syntax error `IN ()`. + */ +// Real-shaped v4s: the variant nibble must be 8/9/a/b, so `1111…` is NOT a +// valid UUID and would fail `@IsUUID` for reasons that have nothing to do +// with the list transform under test. +const A = '0a5d4b1e-1b2c-4d3e-8f90-1234567890ab'; +const B = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + +const parse = (cls: new () => T, query: Record): T => + plainToInstance(cls, query); + +describe('route id-list query params', () => { + it('accepts a single id, still as an array', () => { + const dto = parse(FilterBookingDto, { originYardId: A }); + expect(dto.originYardId).toEqual([A]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('splits a comma-separated list', () => { + const dto = parse(FilterBookingDto, { originYardId: `${A}, ${B}` }); + expect(dto.originYardId).toEqual([A, B]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('accepts the repeated-param form', () => { + const dto = parse(ListTrainSchedulesQueryDto, { destinationStationId: [A, B] }); + expect(dto.destinationStationId).toEqual([A, B]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it.each([undefined, '', ','])('yields undefined, never [], for %p', (raw) => { + expect(parse(FilterBookingDto, { originYardId: raw }).originYardId).toBeUndefined(); + }); + + it('leaves the two ends independent — one side set, the other absent', () => { + const dto = parse(FilterBookingDto, { originYardId: A }); + expect(dto.originYardId).toEqual([A]); + expect(dto.destinationYardId).toBeUndefined(); + }); + + it('still rejects a non-uuid inside the list', () => { + const dto = parse(FilterBookingDto, { originYardId: `${A},not-a-uuid` }); + expect(validateSync(dto)).not.toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-api/src/common/dto/id-list.transform.ts b/apps/edr-freight-api/src/common/dto/id-list.transform.ts new file mode 100644 index 000000000..3bfe94a77 --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/id-list.transform.ts @@ -0,0 +1,29 @@ +import { Transform } from 'class-transformer'; + +/** + * A query param that carries one id, a comma-separated list (`a,b,c`), or the + * same key repeated — and always lands on the DTO as a `string[]`. + * + * Two details matter: + * + * - It yields `undefined`, never `[]`, when nothing usable is left. `@IsOptional` + * then short-circuits, and — more importantly — a repository that does + * `if (ids?.length)` can never be handed an empty array, which TypeORM turns + * into the syntax error `IN ()`. + * - It is backwards compatible with the single-value form these params used to + * take, so existing deep links and saved views keep working unchanged. + * + * Pair it with `@IsUUID(undefined, { each: true })` (or the relevant `each` + * validator) — this only reshapes the value, it does not validate it. + */ +export const IdListParam = () => + Transform(({ value }: { value: unknown }) => { + const raw = Array.isArray(value) ? value : [value]; + const ids = raw + .flatMap((entry) => + entry === undefined || entry === null ? [] : String(entry).split(','), + ) + .map((s) => s.trim()) + .filter(Boolean); + return ids.length ? ids : undefined; + }); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 310364927..ca774ad45 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -78,8 +78,10 @@ export interface BookingListFilterOptions { createdTo?: string; scheduledFrom?: string; scheduledTo?: string; - originYardId?: string; - destinationYardId?: string; + /** Any of these origin yards (OR). ANDed with `destinationYardId`. */ + originYardId?: string[]; + /** Any of these destination yards (OR). ANDed with `originYardId`. */ + destinationYardId?: string[]; isGovernment?: 'true' | 'false'; /** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */ customerKind?: 'SHIPPING_LINE' | 'CUSTOMER'; @@ -1035,14 +1037,17 @@ export class BookingsRepository extends BaseRepository { scheduledTo: options.scheduledTo, }); } - if (options.originYardId) { - qb.andWhere('booking.origin_yard_id = :originYardId', { - originYardId: options.originYardId, + // Each end is its own OR-list, and the two ends AND together — so + // "leaving Nagad or DMP" and "leaving Nagad, arriving Gelan" are both + // expressible. `?.length` guards the empty array: `IN ()` is a syntax error. + if (options.originYardId?.length) { + qb.andWhere('booking.origin_yard_id IN (:...originYardIds)', { + originYardIds: options.originYardId, }); } - if (options.destinationYardId) { - qb.andWhere('booking.destination_yard_id = :destinationYardId', { - destinationYardId: options.destinationYardId, + if (options.destinationYardId?.length) { + qb.andWhere('booking.destination_yard_id IN (:...destinationYardIds)', { + destinationYardIds: options.destinationYardId, }); } if (options.isGovernment === 'true') { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 4ffde3d0d..a404c005e 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -9,6 +9,7 @@ import { TRADE_DIRECTIONS, } from './create-booking.dto'; import { PAYMENT_STATUSES } from '../entities/booking.entity'; +import { IdListParam } from '../../../common/dto/id-list.transform'; export class FilterBookingDto { @ApiPropertyOptional({ enum: BOOKING_STATUSES }) @@ -96,15 +97,23 @@ export class FilterBookingDto { @IsDateString() scheduledTo?: string; - @ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' }) + @ApiPropertyOptional({ + description: + 'Filter by origin yard — one id or a comma-separated list; a booking matches if it leaves ANY of them.', + }) @IsOptional() - @IsUUID() - originYardId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + originYardId?: string[]; - @ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' }) + @ApiPropertyOptional({ + description: + 'Filter by destination yard — one id or a comma-separated list; a booking matches if it arrives at ANY of them. Combined with originYardId by AND.', + }) @IsOptional() - @IsUUID() - destinationYardId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + destinationYardId?: string[]; @ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index d89d43250..7eb2d9a89 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -48,8 +48,10 @@ export interface ContractListFilterOptions { hasClearanceDocuments?: boolean; createdFrom?: string; createdTo?: string; - originYardId?: string; - destinationYardId?: string; + /** Any of these origin yards (OR). ANDed with `destinationYardId`. */ + originYardId?: string[]; + /** Any of these destination yards (OR). ANDed with `originYardId`. */ + destinationYardId?: string[]; } @Injectable() @@ -494,20 +496,25 @@ export class ContractsRepository extends BaseRepository { // Routes are one-to-many (a contract can list several lanes), so origin // and destination each need their own EXISTS — a plain join would // duplicate the contract row per matching route. - if (omit !== 'originYardId' && options.originYardId) { + // Each end is an OR-list, the two ends AND together. Note this still means + // "has a route from one of these origins" AND "has a route to one of these + // destinations" — not necessarily the SAME route, which is what the two + // separate EXISTS have always meant and what the filter bar's two + // independent pickers describe. + if (omit !== 'originYardId' && options.originYardId?.length) { qb.andWhere( 'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' + 'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' + - 'AND cr_o.origin_yard_id = :originYardId)', - { originYardId: options.originYardId }, + 'AND cr_o.origin_yard_id IN (:...originYardIds))', + { originYardIds: options.originYardId }, ); } - if (omit !== 'destinationYardId' && options.destinationYardId) { + if (omit !== 'destinationYardId' && options.destinationYardId?.length) { qb.andWhere( 'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' + 'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' + - 'AND cr_d.destination_yard_id = :destinationYardId)', - { destinationYardId: options.destinationYardId }, + 'AND cr_d.destination_yard_id IN (:...destinationYardIds))', + { destinationYardIds: options.destinationYardId }, ); } } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts index a03d52afb..1d513b2c0 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts @@ -3,6 +3,7 @@ import { Transform } from 'class-transformer'; import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity'; +import { IdListParam } from '../../../common/dto/id-list.transform'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; @@ -62,20 +63,22 @@ export class FilterContractDto { paymentCurrency?: string; @ApiPropertyOptional({ - format: 'uuid', - description: 'Only contracts with a route starting at this yard.', + description: + 'Only contracts with a route starting at one of these yards — a single id or a comma-separated list.', }) @IsOptional() - @IsUUID() - originYardId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + originYardId?: string[]; @ApiPropertyOptional({ - format: 'uuid', - description: 'Only contracts with a route ending at this yard.', + description: + 'Only contracts with a route ending at one of these yards — a single id or a comma-separated list. ANDed with originYardId.', }) @IsOptional() - @IsUUID() - destinationYardId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + destinationYardId?: string[]; @ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts index 7cc6b7115..9f9a68d4e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts @@ -1,6 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { IdListParam } from '../../../common/dto/id-list.transform'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { TRAIN_SCHEDULE_STATUSES, @@ -50,15 +51,22 @@ export class ListTrainSchedulesQueryDto extends PaginationQueryDto { @IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[]) freightType?: TrainScheduleFreightType; - /** Origin station/yard id (exact match). */ - @ApiPropertyOptional({ format: 'uuid' }) + /** Origin station/yard — one id or a comma-separated list; matches ANY of them. */ + @ApiPropertyOptional({ + description: 'Origin station/yard id, or a comma-separated list (matches any of them).', + }) @IsOptional() - @IsUUID() - originStationId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + originStationId?: string[]; - /** Destination station/yard id (exact match). */ - @ApiPropertyOptional({ format: 'uuid' }) + /** Destination station/yard — one id or a comma-separated list; ANDed with the origin. */ + @ApiPropertyOptional({ + description: + 'Destination station/yard id, or a comma-separated list (matches any of them). ANDed with originStationId.', + }) @IsOptional() - @IsUUID() - destinationStationId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + destinationStationId?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 03535135d..6dc8fb901 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -4570,8 +4570,15 @@ export class TrainSchedulingService { const base: FindOptionsWhere = {}; if (allowedDirections) base.direction = In(allowedDirections) as never; if (query.status) base.status = query.status; - if (query.originStationId) base.originStationId = query.originStationId; - if (query.destinationStationId) base.destinationStationId = query.destinationStationId; + // Each end is an OR-list, the two ends AND together (origin-only and + // destination-only are both valid queries). `?.length` guards the empty + // array — `In([])` compiles to `IN ()`, a syntax error. + if (query.originStationId?.length) { + base.originStationId = In(query.originStationId) as never; + } + if (query.destinationStationId?.length) { + base.destinationStationId = In(query.destinationStationId) as never; + } if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never; // Search fans out across every human-recognizable label; each OR variant