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'),