diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts new file mode 100644 index 000000000..5696f00cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts @@ -0,0 +1,61 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateVehicleDto } from './create-vehicle.dto'; + +const base = { + vehicleType: 'TRUCK', + manufacturer: 'IVECO', + model: 'HYT', + year: 2020, + fuelType: 'DIESEL', + capacity: 0, + status: 'ACTIVE', +}; + +const errorsFor = (over: Record) => + validate(plainToInstance(CreateVehicleDto, { ...base, ...over })); + +const plateErrors = ( + errors: Awaited>, + property: string, +) => errors.find((e) => e.property === property && e.constraints?.matches); + +describe('CreateVehicleDto — plate format', () => { + it('accepts a plate like ET-9875', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined(); + }); + + it('accepts a plate like AA-8642', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined(); + }); + + it('upper-cases a lower-case plate before validating', async () => { + const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' }); + expect(dto.plateNumber).toBe('ET-9875'); + expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined(); + }); + + it('rejects a free-text plate like assadasd', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined(); + }); + + it('rejects a plate with no letters or no digits', async () => { + expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined(); + expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined(); + }); + + it('rejects a bad trailer plate but allows a valid one', async () => { + expect( + plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'), + ).toBeDefined(); + expect( + plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'), + ).toBeUndefined(); + }); + + it('allows an empty trailer plate (optional)', async () => { + const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' }); + expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 33d441ecb..4dda3c053 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,7 +1,30 @@ -import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator'; +import { Transform } from 'class-transformer'; import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; +/** + * A vehicle plate is two or three letters, a hyphen, then two to six digits — + * e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer + * all match and the message stays consistent. + */ +export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/; +export const VEHICLE_PLATE_MESSAGE = + 'must be letters and numbers like ET-9875 or AA-8642'; + +/** + * Trim and upper-case a plate before validating, so "et-9875" is accepted. An + * empty optional plate (trailer/power) becomes undefined so @IsOptional skips it + * rather than failing the pattern. + */ +const normalizePlate = ({ value }: { value: unknown }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim().toUpperCase(); + return trimmed === '' ? undefined : trimmed; +}; + export class CreateVehicleDto { + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() plateNumber!: string; @@ -47,10 +70,14 @@ export class CreateVehicleDto { code?: string; @IsOptional() + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() powerPlateNo?: string; @IsOptional() + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() trailerPlateNo?: string; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 8686f9cdf..5d1247358 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -249,6 +249,16 @@ const FleetFormDialog = ({ } } } + + // Format check (e.g. plate numbers). Skipped for an empty optional field — + // "required" above already owns the empty case. Upper-cased to match the + // server, which stores plates upper-case. + if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) { + const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase(); + if (!field.pattern.regex.test(candidate)) { + next[field.name] = field.pattern.message; + } + } }); setErrors(next); return Object.keys(next).length === 0; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 3487cbed8..0e6633eab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -69,6 +69,11 @@ export interface FleetFormFieldDef extends FormFieldDef { * (e.g. a license expiry); "past" (default) = cannot be in the future. */ dateBound?: "past" | "future"; + /** + * Format the value must match, checked on submit. The value is upper-cased and + * trimmed before the test, matching the server. Empty optional fields skip it. + */ + pattern?: { regex: RegExp; message: string; uppercase?: boolean }; } export interface FleetListFilterDef { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index eb1508601..f25be0ff7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -1,5 +1,15 @@ import type { FleetResourceConfig } from "./resources"; +/** + * A plate is two or three letters, a hyphen, then two to six digits — ET-9875, + * AA-8642. Mirrors VEHICLE_PLATE_REGEX on the API so the form and the server + * agree on what a plate looks like. + */ +const PLATE_PATTERN = { + regex: /^[A-Z]{2,3}-\d{2,6}$/, + message: "Use letters and numbers like ET-9875 or AA-8642", +}; + const VEHICLE_TYPE_OPTIONS = [ { label: "Truck", value: "TRUCK" }, { label: "Van", value: "VAN" }, @@ -77,9 +87,9 @@ export const vehiclesConfig: FleetResourceConfig = { ], formFields: [ { name: "code", label: "Code", type: "text" }, - { name: "plateNumber", label: "Power Plate No", type: "text", required: true }, + { name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN }, // { name: "powerPlateNo", label: "Power Plate No", type: "text" }, - { name: "trailerPlateNo", label: "Trailer Plate No", type: "text" }, + { name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN }, { name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS }, { name: "manufacturer", label: "Manufacturer", type: "text", required: true }, { name: "model", label: "Model", type: "text", required: true },