feat(api): accept several yards on each end of a route filter

Bookings, contracts and train schedules all validated originYardId /
destinationYardId (originStationId / destinationStationId) as a single
@IsUUID and matched with `=`, so a list could be narrowed to exactly one
lane. The filter bar can now ask for several stations per end, and each
end independently, which needs the same on the server.

@IdListParam() is the shared transform: one id, a comma-separated list,
or a repeated query param, always landing as a string[]. It yields
undefined rather than [] when nothing usable is left — a repository that
branches on `?.length` can then never hand TypeORM an empty array, which
compiles to the syntax error IN (). It stays backwards compatible with
the single-value form, so existing deep links and saved views are
unaffected.

Matching moves to IN (:...ids) — for contracts inside the two existing
EXISTS subqueries, which keeps meaning "has a route from one of these
origins" AND "has a route to one of these destinations", not necessarily
the same route. All three statements were EXPLAIN-validated against
edr_dev.
This commit is contained in:
Nathnael
2026-08-20 11:24:46 +00:00
parent 684c3173f4
commit 7446adaa88
8 changed files with 165 additions and 40 deletions

View File

@@ -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<Booking> {
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') {

View File

@@ -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()

View File

@@ -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<Contract> {
// 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 },
);
}
}

View File

@@ -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()

View File

@@ -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[];
}

View File

@@ -4570,8 +4570,15 @@ export class TrainSchedulingService {
const base: FindOptionsWhere<TrainSchedule> = {};
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