feat(bookings): add contract validity window and customs clearing features

- Introduced contract validity days, valid from and valid until fields in the booking model.
- Updated booking acceptance logic to enforce validity window constraints.
- Added customs clearing agent and first/last mile pickup/delivery coordinates to the booking model.
- Implemented LocationPicker component for address selection with map integration using Leaflet.
- Enhanced booking review step to display customs clearing agent details.
- Updated API and DTOs to accommodate new booking fields.
- Added migration scripts for database schema changes.
- Implemented tests for booking acceptance and DTO transformations.
This commit is contained in:
Marshal
2026-06-24 00:21:01 +00:00
parent 07cd7dc111
commit 8ef7641048
31 changed files with 1251 additions and 226 deletions

View File

@@ -0,0 +1,47 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { CreateBookingDto } from './create-booking.dto';
/**
* Boolean flags arrive as STRINGS over multipart/form-data ("true" / "false").
* The global freight ValidationPipe runs with enableImplicitConversion = false,
* so only the explicit @Transform on each flag coerces it. This pins that the
* literal string "false" maps to boolean `false` — class-transformer's implicit
* boolean coercion would otherwise turn any non-empty string (including "false")
* into `true`, silently flagging non-hazardous bookings as hazardous.
*/
describe('CreateBookingDto — boolean coercion from multipart strings', () => {
// Mirror the production pipe: explicit transforms only, no implicit coercion.
const toDto = (plain: Record<string, unknown>) =>
plainToInstance(CreateBookingDto, plain, {
enableImplicitConversion: false,
}) as unknown as CreateBookingDto;
it('maps the string "false" to boolean false for every flag', () => {
const dto = toDto({
isHazardous: 'false',
isGovernment: 'false',
customsClearingEnabled: 'false',
});
expect(dto.isHazardous).toBe(false);
expect(dto.isGovernment).toBe(false);
expect(dto.customsClearingEnabled).toBe(false);
});
it('maps the string "true" to boolean true for every flag', () => {
const dto = toDto({
isHazardous: 'true',
isGovernment: 'true',
customsClearingEnabled: 'true',
});
expect(dto.isHazardous).toBe(true);
expect(dto.isGovernment).toBe(true);
expect(dto.customsClearingEnabled).toBe(true);
});
it('still coerces numeric form strings to numbers', () => {
const dto = toDto({ cargoTotalWeightVgm: '12.5' });
expect(dto.cargoTotalWeightVgm).toBe(12.5);
expect(typeof dto.cargoTotalWeightVgm).toBe('number');
});
});

View File

@@ -11,6 +11,8 @@ import {
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
Validate,
@@ -168,11 +170,55 @@ export class CreateBookingDto {
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLat?: number;
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLng?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLat?: number;
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLng?: number;
@ApiPropertyOptional({ description: 'Whether EDR handles customs clearance' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
customsClearingEnabled?: boolean;
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent name (when customs is enabled)' })
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiProperty({ enum: EQUIPMENT_RETURNS })
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;

View File

@@ -1,5 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNumber, IsOptional, IsString, Min, MinLength } from 'class-validator';
import {
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
MinLength,
} from 'class-validator';
export class RequestChangesDto {
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
@@ -8,6 +17,21 @@ export class RequestChangesDto {
note!: string;
}
export class AcceptIntakeDto {
@ApiProperty({
description:
'How many days the contract stays valid, counted from the accept date. ' +
'The contract is valid from now through now + validityDays.',
minimum: 1,
maximum: 365,
example: 30,
})
@IsInt()
@Min(1)
@Max(365)
validityDays!: number;
}
export class StaffRejectDto {
@ApiProperty()
@IsString()