feat(fleet): validate vehicle plate numbers against a letters-and-digits format

Plate, power-plate and trailer accepted any free text — a vehicle could be
saved with a plate of "assadasd". They must be letters, a hyphen, then digits,
like ET-9875 or AA-8642.

The server now enforces it on CreateVehicleDto (and UpdateVehicleDto via
PartialType): each plate is trimmed and upper-cased, then matched against
^[A-Z]{2,3}-\d{2,6}$, so "et-9875" is accepted and stored as ET-9875 while an
empty optional trailer/power plate still passes.

The fleet form gains the same check inline: FleetFormFieldDef takes an optional
pattern, the dialog tests it on submit against the upper-cased value, and the
vehicle config points plate and trailer at a regex that mirrors the server's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-21 09:39:49 +00:00
parent 2349f62247
commit b616f520ae
5 changed files with 116 additions and 3 deletions

View File

@@ -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<string, unknown>) =>
validate(plainToInstance(CreateVehicleDto, { ...base, ...over }));
const plateErrors = (
errors: Awaited<ReturnType<typeof errorsFor>>,
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();
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 },