mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 20:38:17 +00:00
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:
@@ -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 = <T>(cls: new () => T, query: Record<string, unknown>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
@@ -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;
|
||||||
|
});
|
||||||
@@ -78,8 +78,10 @@ export interface BookingListFilterOptions {
|
|||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
scheduledFrom?: string;
|
scheduledFrom?: string;
|
||||||
scheduledTo?: string;
|
scheduledTo?: string;
|
||||||
originYardId?: string;
|
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
||||||
destinationYardId?: string;
|
originYardId?: string[];
|
||||||
|
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
||||||
|
destinationYardId?: string[];
|
||||||
isGovernment?: 'true' | 'false';
|
isGovernment?: 'true' | 'false';
|
||||||
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
|
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
|
||||||
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
|
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
|
||||||
@@ -1035,14 +1037,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
scheduledTo: options.scheduledTo,
|
scheduledTo: options.scheduledTo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.originYardId) {
|
// Each end is its own OR-list, and the two ends AND together — so
|
||||||
qb.andWhere('booking.origin_yard_id = :originYardId', {
|
// "leaving Nagad or DMP" and "leaving Nagad, arriving Gelan" are both
|
||||||
originYardId: options.originYardId,
|
// 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) {
|
if (options.destinationYardId?.length) {
|
||||||
qb.andWhere('booking.destination_yard_id = :destinationYardId', {
|
qb.andWhere('booking.destination_yard_id IN (:...destinationYardIds)', {
|
||||||
destinationYardId: options.destinationYardId,
|
destinationYardIds: options.destinationYardId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.isGovernment === 'true') {
|
if (options.isGovernment === 'true') {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TRADE_DIRECTIONS,
|
TRADE_DIRECTIONS,
|
||||||
} from './create-booking.dto';
|
} from './create-booking.dto';
|
||||||
import { PAYMENT_STATUSES } from '../entities/booking.entity';
|
import { PAYMENT_STATUSES } from '../entities/booking.entity';
|
||||||
|
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||||
|
|
||||||
export class FilterBookingDto {
|
export class FilterBookingDto {
|
||||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||||
@@ -96,15 +97,23 @@ export class FilterBookingDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledTo?: string;
|
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()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
originYardId?: string;
|
@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()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
destinationYardId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
destinationYardId?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
|
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ export interface ContractListFilterOptions {
|
|||||||
hasClearanceDocuments?: boolean;
|
hasClearanceDocuments?: boolean;
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
originYardId?: string;
|
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
||||||
destinationYardId?: string;
|
originYardId?: string[];
|
||||||
|
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
||||||
|
destinationYardId?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -494,20 +496,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
// Routes are one-to-many (a contract can list several lanes), so origin
|
// Routes are one-to-many (a contract can list several lanes), so origin
|
||||||
// and destination each need their own EXISTS — a plain join would
|
// and destination each need their own EXISTS — a plain join would
|
||||||
// duplicate the contract row per matching route.
|
// 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(
|
qb.andWhere(
|
||||||
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
|
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
|
||||||
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
|
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
|
||||||
'AND cr_o.origin_yard_id = :originYardId)',
|
'AND cr_o.origin_yard_id IN (:...originYardIds))',
|
||||||
{ originYardId: options.originYardId },
|
{ originYardIds: options.originYardId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (omit !== 'destinationYardId' && options.destinationYardId) {
|
if (omit !== 'destinationYardId' && options.destinationYardId?.length) {
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
|
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
|
||||||
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
|
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
|
||||||
'AND cr_d.destination_yard_id = :destinationYardId)',
|
'AND cr_d.destination_yard_id IN (:...destinationYardIds))',
|
||||||
{ destinationYardId: options.destinationYardId },
|
{ destinationYardIds: options.destinationYardId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
|
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 TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
@@ -62,20 +63,22 @@ export class FilterContractDto {
|
|||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'uuid',
|
description:
|
||||||
description: 'Only contracts with a route starting at this yard.',
|
'Only contracts with a route starting at one of these yards — a single id or a comma-separated list.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
originYardId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
originYardId?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'uuid',
|
description:
|
||||||
description: 'Only contracts with a route ending at this yard.',
|
'Only contracts with a route ending at one of these yards — a single id or a comma-separated list. ANDed with originYardId.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
destinationYardId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
destinationYardId?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
import {
|
import {
|
||||||
TRAIN_SCHEDULE_STATUSES,
|
TRAIN_SCHEDULE_STATUSES,
|
||||||
@@ -50,15 +51,22 @@ export class ListTrainSchedulesQueryDto extends PaginationQueryDto {
|
|||||||
@IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[])
|
@IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[])
|
||||||
freightType?: TrainScheduleFreightType;
|
freightType?: TrainScheduleFreightType;
|
||||||
|
|
||||||
/** Origin station/yard id (exact match). */
|
/** Origin station/yard — one id or a comma-separated list; matches ANY of them. */
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Origin station/yard id, or a comma-separated list (matches any of them).',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
originStationId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
originStationId?: string[];
|
||||||
|
|
||||||
/** Destination station/yard id (exact match). */
|
/** Destination station/yard — one id or a comma-separated list; ANDed with the origin. */
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Destination station/yard id, or a comma-separated list (matches any of them). ANDed with originStationId.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
destinationStationId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
destinationStationId?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4570,8 +4570,15 @@ export class TrainSchedulingService {
|
|||||||
const base: FindOptionsWhere<TrainSchedule> = {};
|
const base: FindOptionsWhere<TrainSchedule> = {};
|
||||||
if (allowedDirections) base.direction = In(allowedDirections) as never;
|
if (allowedDirections) base.direction = In(allowedDirections) as never;
|
||||||
if (query.status) base.status = query.status;
|
if (query.status) base.status = query.status;
|
||||||
if (query.originStationId) base.originStationId = query.originStationId;
|
// Each end is an OR-list, the two ends AND together (origin-only and
|
||||||
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
// 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;
|
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
|
||||||
|
|
||||||
// Search fans out across every human-recognizable label; each OR variant
|
// Search fans out across every human-recognizable label; each OR variant
|
||||||
|
|||||||
Reference in New Issue
Block a user