diff --git a/apps/api/src/lib/inputValidation.test.ts b/apps/api/src/lib/inputValidation.test.ts new file mode 100644 index 0000000..6af6a73 --- /dev/null +++ b/apps/api/src/lib/inputValidation.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeAndFormat, toTitleCase, toTitleCaseAll, toLowerCase, toUpperCase, preserveOriginal, getMaxLength } from './inputValidation' + +describe('toTitleCase', () => { + it('uppercases first letter and lowercases rest of each word', () => { + expect(toTitleCase('john doe')).toBe('John Doe') + expect(toTitleCase('MARIA')).toBe('Maria') + expect(toTitleCase('new york')).toBe('New York') + }) + + it('preserves numbers and special chars that survive sanitization', () => { + expect(toTitleCase('123 main st.')).toBe('123 Main St.') + }) + + it('handles single word', () => { + expect(toTitleCase('hello')).toBe('Hello') + }) +}) + +describe('toTitleCaseAll', () => { + it('uppercases first letter of every word', () => { + expect(toTitleCaseAll('acme corporation ltd.')).toBe('Acme Corporation Ltd.') + expect(toTitleCaseAll('123 main street, apt 4b')).toBe('123 Main Street, Apt 4b') + }) +}) + +describe('toLowerCase', () => { + it('lowercases all characters', () => { + expect(toLowerCase('John.Doe@Example.COM')).toBe('john.doe@example.com') + expect(toLowerCase('ALLCAPS')).toBe('allcaps') + }) +}) + +describe('toUpperCase', () => { + it('uppercases letters, leaves numbers unchanged', () => { + expect(toUpperCase('abc-123')).toBe('ABC-123') + expect(toUpperCase('ab123cd')).toBe('AB123CD') + }) +}) + +describe('preserveOriginal', () => { + it('returns the string unchanged', () => { + expect(preserveOriginal('SW1A 1AA')).toBe('SW1A 1AA') + expect(preserveOriginal('12345')).toBe('12345') + }) +}) + +describe('sanitizeAndFormat', () => { + // ─── Title Case fields + + it('formats name fields (title case, max 50)', () => { + expect(sanitizeAndFormat('john doe', 'name')).toBe('John Doe') + expect(sanitizeAndFormat('MARIA', 'name')).toBe('Maria') + }) + + it('formats streetAddress (title case, max 100)', () => { + expect(sanitizeAndFormat('123 main st.', 'streetAddress')).toBe('123 Main St') + expect(sanitizeAndFormat('elm street 42', 'streetAddress')).toBe('Elm Street 42') + }) + + it('formats city (title case, max 50)', () => { + expect(sanitizeAndFormat('new york', 'city')).toBe('New York') + }) + + it('formats pickupLocation and returnLocation', () => { + expect(sanitizeAndFormat('airport terminal 1', 'pickupLocation')).toBe('Airport Terminal 1') + expect(sanitizeAndFormat('downtown garage', 'returnLocation')).toBe('Downtown Garage') + }) + + it('formats country (title case)', () => { + expect(sanitizeAndFormat('united kingdom', 'country')).toBe('United Kingdom') + }) + + // ─── Title Case All fields + + it('formats fullAddress (title case all, max 200)', () => { + expect(sanitizeAndFormat('123 main street, apt 4b', 'fullAddress')).toBe('123 Main Street, Apt 4b') + }) + + it('formats commercialName (title case all, max 100)', () => { + expect(sanitizeAndFormat('acme corporation', 'commercialName')).toBe('Acme Corporation') + }) + + it('formats legalCompanyName (title case all, max 100)', () => { + expect(sanitizeAndFormat('global rentals llc', 'legalCompanyName')).toBe('Global Rentals Llc') + }) + + // ─── Email + + it('formats email (lowercase)', () => { + expect(sanitizeAndFormat('John.Doe@Example.COM', 'email')).toBe('john.doe@example.com') + }) + + // ─── Uppercase fields + + it('formats licensePlate (uppercase, preserves hyphens)', () => { + expect(sanitizeAndFormat('abc-123', 'licensePlate')).toBe('ABC-123') + }) + + it('formats VIN (uppercase)', () => { + expect(sanitizeAndFormat('1hgbh41jxmn109186', 'vin')).toBe('1HGBH41JXMN109186') + }) + + it('formats passportNumber (uppercase)', () => { + expect(sanitizeAndFormat('ab123456', 'passportNumber')).toBe('AB123456') + }) + + it('formats driverLicenseNumber (uppercase, preserves hyphens)', () => { + expect(sanitizeAndFormat('d123-456-789', 'driverLicenseNumber')).toBe('D123-456-789') + }) + + it('formats licenseCategory (uppercase)', () => { + expect(sanitizeAndFormat('b', 'licenseCategory')).toBe('B') + }) + + // ─── Car fields + + it('formats carMark (title case)', () => { + expect(sanitizeAndFormat('TOYOTA', 'carMark')).toBe('Toyota') + expect(sanitizeAndFormat('mercedes-benz', 'carMark')).toBe('Mercedes-Benz') + }) + + it('formats carModel (title case)', () => { + expect(sanitizeAndFormat('corolla', 'carModel')).toBe('Corolla') + }) + + it('formats carColor (title case)', () => { + expect(sanitizeAndFormat('midnight-blue', 'carColor')).toBe('Midnight-Blue') + }) + + // ─── ZIP Code (preserved) + + it('preserves ZIP code format', () => { + expect(sanitizeAndFormat('12345', 'zipCode')).toBe('12345') + expect(sanitizeAndFormat('SW1A 1AA', 'zipCode')).toBe('SW1A 1AA') + }) + + // ─── Sanitization + + it('removes disallowed characters', () => { + expect(sanitizeAndFormat('John!@#', 'name')).toBe('John') + }) + + it('strips disallowed characters including angle brackets', () => { + const result = sanitizeAndFormat('test', 'name') + expect(result).toBe('Testscriptalertscript') + }) + + // ─── Truncation + + it('truncates to max length', () => { + const longName = 'A'.repeat(60) + expect(sanitizeAndFormat(longName, 'name')).toHaveLength(50) + }) + + it('does not truncate strings within limit', () => { + expect(sanitizeAndFormat('John', 'name')).toHaveLength(4) + }) +}) + +describe('getMaxLength', () => { + it('returns correct max lengths', () => { + expect(getMaxLength('name')).toBe(50) + expect(getMaxLength('streetAddress')).toBe(100) + expect(getMaxLength('fullAddress')).toBe(200) + expect(getMaxLength('email')).toBe(100) + expect(getMaxLength('licensePlate')).toBe(30) + expect(getMaxLength('zipCode')).toBe(10) + expect(getMaxLength('carMark')).toBe(30) + }) +}) + diff --git a/apps/api/src/lib/inputValidation.ts b/apps/api/src/lib/inputValidation.ts index aeef482..c0e0288 100644 --- a/apps/api/src/lib/inputValidation.ts +++ b/apps/api/src/lib/inputValidation.ts @@ -13,45 +13,22 @@ // ─── Character class definitions ─────────────────────────────── const LETTERS_NUMBERS = 'a-zA-Z0-9' - -/** Letters, numbers, spaces, hyphen, slash */ -const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/` - -/** Letters, spaces, hyphen */ +const LETTERS_NUMBERS_HYPHEN = `${LETTERS_NUMBERS}\\-` const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-' - -/** Letters, numbers, spaces, hyphen, slash, comma (full address / company names) */ +const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/` const EXTENDED_TEXT = `${BASE_TEXT},` -/** Letters only */ -const LETTERS_ONLY = 'a-zA-Z' - // ─── Field type definitions ──────────────────────────────────── -type FieldType = - | 'name' - | 'nationality' - | 'streetAddress' - | 'city' - | 'pickupLocation' - | 'returnLocation' - | 'country' - | 'fullAddress' - | 'commercialName' - | 'legalCompanyName' +export type FieldType = + | 'name' | 'nationality' | 'streetAddress' | 'city' + | 'pickupLocation' | 'returnLocation' | 'country' + | 'fullAddress' | 'commercialName' | 'legalCompanyName' | 'email' - | 'licensePlate' - | 'vin' - | 'cin' - | 'passportNumber' - | 'internationalPermitNumber' - | 'driverLicenseNumber' - | 'licenseCategory' - | 'iceNumber' - | 'commercialRegistry' - | 'carMark' - | 'carModel' - | 'carColor' + | 'licensePlate' | 'vin' | 'cin' | 'passportNumber' + | 'internationalPermitNumber' | 'driverLicenseNumber' + | 'licenseCategory' | 'iceNumber' | 'commercialRegistry' + | 'carMark' | 'carModel' | 'carColor' | 'zipCode' interface FieldConfig { @@ -67,7 +44,7 @@ export function toTitleCase(str: string): string { return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) } -/** Uppercase first letter of every word (same logic as toTitleCase at word boundaries) */ +/** Uppercase first letter of every word */ export function toTitleCaseAll(str: string): string { return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) } @@ -81,7 +58,6 @@ export function toUpperCase(str: string): string { return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase()) } -/** Identity — no transformation */ export function preserveOriginal(str: string): string { return str } @@ -90,40 +66,40 @@ export function preserveOriginal(str: string): string { const FIELD_CONFIGS: Record = { // Group 1: Title Case - name: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, - nationality: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, - streetAddress: { maxLength: 100, allowedPattern: new RegExp(`^[${BASE_TEXT}]*$`), transform: toTitleCase }, - city: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, - pickupLocation: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, - returnLocation: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, - country: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, + name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase }, + nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase }, + streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase }, + city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase }, + pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase }, + returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase }, + country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase }, // Group 2: Title Case All - fullAddress: { maxLength: 200, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll }, - commercialName: { maxLength: 100, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll }, - legalCompanyName: { maxLength: 100, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll }, + fullAddress: { maxLength: 200, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll }, + commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll }, + legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll }, - // Group 3: Lowercase — email handled separately with format validation - email: { maxLength: 100, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}@._%\\+\\-]*$`), transform: toLowerCase }, + // Group 3: Lowercase — email + email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase }, - // Group 4: Uppercase (letters and numbers only, no spaces/hyphens) - licensePlate: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, - vin: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, - cin: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, - passportNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, - internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, - driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, - licenseCategory: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, - iceNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, - commercialRegistry: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + // Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123") + licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, + vin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, + cin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, + passportNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, + internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, + driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, + licenseCategory: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, + iceNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, + commercialRegistry: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase }, // Group 5: Car fields - carMark: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase }, - carModel: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase }, - carColor: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase }, + carMark: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase }, + carModel: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase }, + carColor: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase }, - // Group 6: Preserved - zipCode: { maxLength: 10, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\-]*$`), transform: preserveOriginal }, + // Group 6: Preserved (spaces for UK postcodes like "SW1A 1AA") + zipCode: { maxLength: 10, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: preserveOriginal }, } // ─── Public API ──────────────────────────────────────────────── @@ -158,4 +134,3 @@ export function sanitizeAndFormat(value: string, fieldType: FieldType): string { export function getMaxLength(fieldType: FieldType): number { return FIELD_CONFIGS[fieldType].maxLength } - diff --git a/apps/api/src/lib/zodValidation.ts b/apps/api/src/lib/zodValidation.ts index cb1370b..0141a69 100644 --- a/apps/api/src/lib/zodValidation.ts +++ b/apps/api/src/lib/zodValidation.ts @@ -1,145 +1,109 @@ -/** - * Zod integration helpers for the input validation/formatting library. - * - * Provides pre-built Zod refinements and transforms that can be - * chained onto `.string()` schemas throughout the API. - * - * Usage: - * import { textField, emailField } from '../lib/zodValidation' - * const schema = z.object({ - * firstName: textField('name'), - * email: emailField(), - * }) - */ - import { z } from 'zod' import { sanitizeAndFormat, getMaxLength } from './inputValidation' import type { FieldType } from './inputValidation' -// ─── Character validation (inline refine) ────────────────────── +// ─── Regex constants ─────────────────────────────────────────── -/** Global allowed character pattern from the validation plan */ const GLOBAL_ALLOWED = /^[a-zA-Z0-9@\-/\s]*$/ -export const globalCharRefine = (val: string) => - GLOBAL_ALLOWED.test(val) || { message: 'Only letters, numbers, @, -, and / are allowed' } - -// ─── Email validation ────────────────────────────────────────── - const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/ -export const emailFormatRefine = (val: string) => - EMAIL_REGEX.test(val) || { message: 'Please enter a valid email address' } +/** Morocco phone: (+212|00212|0) + 5/6/7 + 8 digits (optional spaces) */ +const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/ -// ─── Pre-built Zod string wrappers ───────────────────────────── +// ─── Phone sanitization ─────────────────────────────────────── -/** - * Returns the base Zod chain: .string() → .min(1) → character refine → trim → transform - * If optional is true, wraps in .optional() at the end. - */ -function baseTextField(fieldType: FieldType, optional = false) { +function sanitizePhone(raw: string): string { + let out = '' + for (const ch of raw) { + if (/[\d\s+]/.test(ch)) out += ch + } + return out.trim() +} + +// ─── Required fields ─────────────────────────────────────────── + +function applyFieldRules(fieldType: FieldType) { const maxLength = getMaxLength(fieldType) - let schema = z + return z .string() .min(1, { message: 'This field is required' }) - .refine(globalCharRefine) .trim() - .transform((val) => sanitizeAndFormat(val, fieldType)) - .refine( - (val) => val.length <= maxLength, - { message: `Maximum ${maxLength} characters allowed` } - ) - - if (optional) { - // For optional fields: allow undefined input, but if a value is provided, apply all rules - return z - .string() - .optional() - .transform((val) => { - if (val === undefined || val === null) return undefined - return sanitizeAndFormat(val, fieldType) - }) - .refine( - (val) => val === undefined || val.length === 0 || (globalCharRefine(val as string) === true), - { message: 'Only letters, numbers, @, -, and / are allowed' } + .transform((val: string) => sanitizeAndFormat(val, fieldType)) + .pipe( + z.string().refine( + (val: string) => val.length <= maxLength, + { message: 'Maximum ' + maxLength + ' characters allowed' } ) - } - - return schema + ) } -/** - * Text field with title case formatting. - * fieldType must be one of: name, nationality, streetAddress, city, - * pickupLocation, returnLocation, country - */ +// ─── Optional fields ─────────────────────────────────────────── + +function applyOptionalFieldRules(fieldType: FieldType) { + const maxLength = getMaxLength(fieldType) + return z + .string() + .optional() + .transform((val) => { + if (val === undefined || val === null || val.trim() === '') return undefined + return sanitizeAndFormat(val, fieldType) + }) + .pipe( + z.string().optional().refine( + (val) => val === undefined || val.length <= maxLength, + { message: 'Maximum ' + maxLength + ' characters allowed' } + ) + ) +} + +// ─── Exported helpers ────────────────────────────────────────── + export function textField(fieldType: FieldType) { - return baseTextField(fieldType, false) + return applyFieldRules(fieldType) } - -/** Optional version of textField */ export function optionalTextField(fieldType: FieldType) { - return baseTextField(fieldType, true) + return applyOptionalFieldRules(fieldType) } - -/** - * Title-case-all fields: fullAddress, commercialName, legalCompanyName - */ export function titleCaseAllField(fieldType: FieldType) { - return baseTextField(fieldType, false) + return applyFieldRules(fieldType) } - export function optionalTitleCaseAllField(fieldType: FieldType) { - return baseTextField(fieldType, true) + return applyOptionalFieldRules(fieldType) } - -/** - * Uppercase fields (letters only in allowed chars): licensePlate, vin, cin, - * passportNumber, internationalPermitNumber, driverLicenseNumber, - * licenseCategory, iceNumber, commercialRegistry - */ export function upperField(fieldType: FieldType) { - return baseTextField(fieldType, false) + return applyFieldRules(fieldType) } - export function optionalUpperField(fieldType: FieldType) { - return baseTextField(fieldType, true) + return applyOptionalFieldRules(fieldType) } - -/** - * Car fields (title case, letters/numbers/spaces/hyphen allowed): - * carMark, carModel, carColor - */ export function carTextField(fieldType: FieldType) { - return baseTextField(fieldType, false) + return applyFieldRules(fieldType) } - export function optionalCarTextField(fieldType: FieldType) { - return baseTextField(fieldType, true) + return applyOptionalFieldRules(fieldType) } - -/** - * Preserved format field: zipCode - */ export function zipField() { - return baseTextField('zipCode', false) + return applyFieldRules('zipCode') } - export function optionalZipField() { - return baseTextField('zipCode', true) + return applyOptionalFieldRules('zipCode') } -/** - * Email field with full validation chain - */ +// ─── Email fields ────────────────────────────────────────────── + export function emailField() { return z .string() .min(1, { message: 'Email is required' }) - .refine(globalCharRefine) .trim() - .transform((val) => sanitizeAndFormat(val, 'email')) - .refine(emailFormatRefine) + .transform((val: string) => sanitizeAndFormat(val, 'email')) + .pipe( + z.string().refine( + (val: string) => EMAIL_REGEX.test(val), + { message: 'Please enter a valid email address' } + ) + ) } export function optionalEmailField() { @@ -150,9 +114,42 @@ export function optionalEmailField() { if (val === undefined || val === null || val.trim() === '') return undefined return sanitizeAndFormat(val, 'email') }) - .refine( - (val) => val === undefined || emailFormatRefine(val as string) === true, - { message: 'Please enter a valid email address' } + .pipe( + z.string().optional().refine( + (val) => val === undefined || EMAIL_REGEX.test(val), + { message: 'Please enter a valid email address' } + ) ) } +// ─── Phone fields ────────────────────────────────────────────── + +export function phoneField() { + return z + .string() + .min(1, { message: 'Phone number is required' }) + .trim() + .transform((val: string) => sanitizePhone(val)) + .pipe( + z.string().refine( + (val: string) => MA_PHONE_REGEX.test(val), + { message: 'Please enter a valid Morocco phone number' } + ) + ) +} + +export function optionalPhoneField() { + return z + .string() + .optional() + .transform((val) => { + if (val === undefined || val === null || val.trim() === '') return undefined + return sanitizePhone(val) + }) + .pipe( + z.string().optional().refine( + (val) => val === undefined || MA_PHONE_REGEX.test(val), + { message: 'Please enter a valid Morocco phone number' } + ) + ) +} diff --git a/apps/api/src/modules/companies/company.schemas.ts b/apps/api/src/modules/companies/company.schemas.ts index acd4188..9a34f0b 100644 --- a/apps/api/src/modules/companies/company.schemas.ts +++ b/apps/api/src/modules/companies/company.schemas.ts @@ -1,9 +1,10 @@ import { z } from 'zod' +import { optionalTextField, optionalTitleCaseAllField, optionalUpperField, optionalEmailField, optionalPhoneField } from '../../lib/zodValidation' export const companySchema = z.object({ - name: z.string().min(1).optional(), - email: z.string().email().optional(), - phone: z.string().optional(), + name: optionalTextField('commercialName'), + email: optionalEmailField(), + phone: optionalPhoneField(), address: z.record(z.unknown()).optional(), }) @@ -12,18 +13,18 @@ export const brandSchema = z.object({ tagline: z.string().optional(), primaryColor: z.string().optional(), accentColor: z.string().optional(), - publicEmail: z.string().email().optional(), - publicPhone: z.string().optional(), + publicEmail: optionalEmailField(), + publicPhone: optionalPhoneField(), publicAddress: z.string().optional(), - publicCity: z.string().optional(), - publicCountry: z.string().optional(), + publicCity: optionalTextField('city'), + publicCountry: optionalTextField('country'), websiteUrl: z.string().url().optional(), whatsappNumber: z.string().optional(), defaultLocale: z.string().optional(), defaultCurrency: z.literal('MAD').optional(), amanpayMerchantId: z.string().optional(), amanpaySecretKey: z.string().optional(), - paypalEmail: z.string().email().optional(), + paypalEmail: optionalEmailField(), paypalMerchantId: z.string().optional(), isListedOnMarketplace: z.boolean().optional(), homePageConfig: z.object({ @@ -97,8 +98,8 @@ export const brandSchema = z.object({ }) export const contractSettingsSchema = z.object({ - legalName: z.string().optional(), - registrationNumber: z.string().optional(), + legalName: optionalTitleCaseAllField('legalCompanyName'), + registrationNumber: optionalUpperField('commercialRegistry'), taxId: z.string().optional(), terms: z.string().optional(), fuelPolicy: z.string().optional(), @@ -147,8 +148,8 @@ export const accountingSettingsSchema = z.object({ reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(), fiscalYearStart: z.number().int().min(1).max(12).optional(), currency: z.literal('MAD').optional(), - accountantEmail: z.string().email().optional(), - accountantName: z.string().optional(), + accountantEmail: optionalEmailField(), + accountantName: optionalTextField('name'), autoSendReport: z.boolean().optional(), reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(), }) diff --git a/apps/api/src/modules/customers/customer.schemas.ts b/apps/api/src/modules/customers/customer.schemas.ts index ea2f9c6..f1a673c 100644 --- a/apps/api/src/modules/customers/customer.schemas.ts +++ b/apps/api/src/modules/customers/customer.schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { textField, optionalTextField, emailField, optionalUpperField, optionalPhoneField } from '../../lib/zodValidation' export const paginationSchema = z.object({ page: z.coerce.number().int().min(1).max(10000).default(1), @@ -6,20 +7,20 @@ export const paginationSchema = z.object({ }) export const customerSchema = z.object({ - firstName: z.string().min(1).max(100).trim(), - lastName: z.string().min(1).max(100).trim(), - email: z.string().email().max(255).trim().toLowerCase(), - phone: z.string().max(30).trim().optional(), - driverLicense: z.string().max(50).trim().optional(), + firstName: textField('name'), + lastName: textField('name'), + email: emailField(), + phone: optionalPhoneField(), + driverLicense: optionalUpperField('driverLicenseNumber'), dateOfBirth: z.string().datetime().optional(), - nationality: z.string().max(100).trim().optional(), + nationality: optionalTextField('nationality'), address: z.record(z.unknown()).optional(), notes: z.string().max(2000).trim().optional(), licenseExpiry: z.string().datetime().optional(), licenseIssuedAt: z.string().datetime().optional(), - licenseCountry: z.string().max(100).trim().optional(), - licenseNumber: z.string().max(50).trim().optional(), - licenseCategory: z.string().max(20).trim().optional(), + licenseCountry: optionalTextField('country'), + licenseNumber: optionalUpperField('driverLicenseNumber'), + licenseCategory: optionalUpperField('licenseCategory'), }) export const listQuerySchema = paginationSchema.extend({ diff --git a/apps/api/src/modules/reservations/reservation.schemas.ts b/apps/api/src/modules/reservations/reservation.schemas.ts index 4998957..37ee45e 100644 --- a/apps/api/src/modules/reservations/reservation.schemas.ts +++ b/apps/api/src/modules/reservations/reservation.schemas.ts @@ -1,15 +1,16 @@ import { z } from 'zod' +import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalPhoneField } from '../../lib/zodValidation' export const additionalDriverSchema = z.object({ - firstName: z.string().min(1), - lastName: z.string().min(1), - email: z.string().email().optional(), - phone: z.string().optional(), - driverLicense: z.string().min(1), + firstName: textField('name'), + lastName: textField('name'), + email: optionalEmailField(), + phone: optionalPhoneField(), + driverLicense: upperField('driverLicenseNumber'), licenseExpiry: z.string().datetime().optional(), licenseIssuedAt: z.string().datetime().optional(), dateOfBirth: z.string().datetime().optional(), - nationality: z.string().optional(), + nationality: optionalTextField('nationality'), }) export const inspectionSchema = z.object({ @@ -35,8 +36,8 @@ export const createSchema = z.object({ customerId: z.string().cuid(), startDate: z.string().datetime(), endDate: z.string().datetime(), - pickupLocation: z.string().optional(), - returnLocation: z.string().optional(), + pickupLocation: optionalTextField('pickupLocation'), + returnLocation: optionalTextField('returnLocation'), offerId: z.string().cuid().optional(), promoCodeUsed: z.string().optional(), depositAmount: z.number().int().min(0).default(0), @@ -49,8 +50,8 @@ export const createSchema = z.object({ export const updateSchema = z.object({ startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), - pickupLocation: z.string().optional().nullable(), - returnLocation: z.string().optional().nullable(), + pickupLocation: optionalTextField('pickupLocation').nullable(), + returnLocation: optionalTextField('returnLocation').nullable(), depositAmount: z.number().int().min(0).optional(), notes: z.string().optional().nullable(), paymentMode: z.string().max(50).optional().nullable(), diff --git a/apps/api/src/modules/vehicles/vehicle.schemas.ts b/apps/api/src/modules/vehicles/vehicle.schemas.ts index 3dda342..8fdc50f 100644 --- a/apps/api/src/modules/vehicles/vehicle.schemas.ts +++ b/apps/api/src/modules/vehicles/vehicle.schemas.ts @@ -1,12 +1,14 @@ import { z } from 'zod' +import { sanitizeAndFormat } from '../../lib/inputValidation' +import { carTextField, upperField, optionalUpperField } from '../../lib/zodValidation' export const vehicleSchema = z.object({ - make: z.string().min(1), - model: z.string().min(1), + make: carTextField('carMark'), + model: carTextField('carModel'), year: z.number().int().min(1990).max(new Date().getFullYear() + 1), - color: z.string().default(''), - licensePlate: z.string().min(1), - vin: z.string().optional(), + color: z.string().optional().default('').transform((v: string) => v ? sanitizeAndFormat(v, 'carColor') : ''), + licensePlate: upperField('licensePlate'), + vin: optionalUpperField('vin'), category: z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK']), seats: z.number().int().min(1).max(20).default(5), transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'),