fix input field requirement

This commit is contained in:
root
2026-06-11 15:49:31 -04:00
parent 37a6ed8a76
commit 6b97c895e0
7 changed files with 349 additions and 200 deletions
+172
View File
@@ -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<script>alert(1)</script>', '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)
})
})
+38 -63
View File
@@ -13,45 +13,22 @@
// ─── Character class definitions ─────────────────────────────── // ─── Character class definitions ───────────────────────────────
const LETTERS_NUMBERS = 'a-zA-Z0-9' const LETTERS_NUMBERS = 'a-zA-Z0-9'
const LETTERS_NUMBERS_HYPHEN = `${LETTERS_NUMBERS}\\-`
/** Letters, numbers, spaces, hyphen, slash */
const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/`
/** Letters, spaces, hyphen */
const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-' const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-'
const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/`
/** Letters, numbers, spaces, hyphen, slash, comma (full address / company names) */
const EXTENDED_TEXT = `${BASE_TEXT},` const EXTENDED_TEXT = `${BASE_TEXT},`
/** Letters only */
const LETTERS_ONLY = 'a-zA-Z'
// ─── Field type definitions ──────────────────────────────────── // ─── Field type definitions ────────────────────────────────────
type FieldType = export type FieldType =
| 'name' | 'name' | 'nationality' | 'streetAddress' | 'city'
| 'nationality' | 'pickupLocation' | 'returnLocation' | 'country'
| 'streetAddress' | 'fullAddress' | 'commercialName' | 'legalCompanyName'
| 'city'
| 'pickupLocation'
| 'returnLocation'
| 'country'
| 'fullAddress'
| 'commercialName'
| 'legalCompanyName'
| 'email' | 'email'
| 'licensePlate' | 'licensePlate' | 'vin' | 'cin' | 'passportNumber'
| 'vin' | 'internationalPermitNumber' | 'driverLicenseNumber'
| 'cin' | 'licenseCategory' | 'iceNumber' | 'commercialRegistry'
| 'passportNumber' | 'carMark' | 'carModel' | 'carColor'
| 'internationalPermitNumber'
| 'driverLicenseNumber'
| 'licenseCategory'
| 'iceNumber'
| 'commercialRegistry'
| 'carMark'
| 'carModel'
| 'carColor'
| 'zipCode' | 'zipCode'
interface FieldConfig { 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()) 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 { export function toTitleCaseAll(str: string): string {
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) 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()) return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase())
} }
/** Identity — no transformation */
export function preserveOriginal(str: string): string { export function preserveOriginal(str: string): string {
return str return str
} }
@@ -90,40 +66,40 @@ export function preserveOriginal(str: string): string {
const FIELD_CONFIGS: Record<FieldType, FieldConfig> = { const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
// Group 1: Title Case // Group 1: Title Case
name: { 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 }, nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
streetAddress: { maxLength: 100, allowedPattern: new RegExp(`^[${BASE_TEXT}]*$`), transform: toTitleCase }, streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
city: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
pickupLocation: { 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(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
country: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
// Group 2: Title Case All // Group 2: Title Case All
fullAddress: { maxLength: 200, 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 }, commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
legalCompanyName: { 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 // Group 3: Lowercase — email
email: { maxLength: 100, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}@._%\\+\\-]*$`), transform: toLowerCase }, email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
// Group 4: Uppercase (letters and numbers only, no spaces/hyphens) // Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
licensePlate: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
vin: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, vin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
cin: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, cin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
passportNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, passportNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
licenseCategory: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, licenseCategory: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
iceNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, iceNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
commercialRegistry: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, commercialRegistry: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
// Group 5: Car fields // Group 5: Car fields
carMark: { 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 }, carModel: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
carColor: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase }, carColor: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
// Group 6: Preserved // Group 6: Preserved (spaces for UK postcodes like "SW1A 1AA")
zipCode: { maxLength: 10, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\-]*$`), transform: preserveOriginal }, zipCode: { maxLength: 10, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: preserveOriginal },
} }
// ─── Public API ──────────────────────────────────────────────── // ─── Public API ────────────────────────────────────────────────
@@ -158,4 +134,3 @@ export function sanitizeAndFormat(value: string, fieldType: FieldType): string {
export function getMaxLength(fieldType: FieldType): number { export function getMaxLength(fieldType: FieldType): number {
return FIELD_CONFIGS[fieldType].maxLength return FIELD_CONFIGS[fieldType].maxLength
} }
+98 -101
View File
@@ -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 { z } from 'zod'
import { sanitizeAndFormat, getMaxLength } from './inputValidation' import { sanitizeAndFormat, getMaxLength } from './inputValidation'
import type { FieldType } 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]*$/ 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,}$/ const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
export const emailFormatRefine = (val: string) => /** Morocco phone: (+212|00212|0) + 5/6/7 + 8 digits (optional spaces) */
EMAIL_REGEX.test(val) || { message: 'Please enter a valid email address' } const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/
// ─── Pre-built Zod string wrappers ───────────────────────────── // ─── Phone sanitization ───────────────────────────────────────
/** function sanitizePhone(raw: string): string {
* Returns the base Zod chain: .string() → .min(1) → character refine → trim → transform let out = ''
* If optional is true, wraps in .optional() at the end. for (const ch of raw) {
*/ if (/[\d\s+]/.test(ch)) out += ch
function baseTextField(fieldType: FieldType, optional = false) { }
return out.trim()
}
// ─── Required fields ───────────────────────────────────────────
function applyFieldRules(fieldType: FieldType) {
const maxLength = getMaxLength(fieldType) const maxLength = getMaxLength(fieldType)
let schema = z return z
.string() .string()
.min(1, { message: 'This field is required' }) .min(1, { message: 'This field is required' })
.refine(globalCharRefine)
.trim() .trim()
.transform((val) => sanitizeAndFormat(val, fieldType)) .transform((val: string) => sanitizeAndFormat(val, fieldType))
.refine( .pipe(
(val) => val.length <= maxLength, z.string().refine(
{ message: `Maximum ${maxLength} characters allowed` } (val: string) => 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' }
) )
} )
return schema
} }
/** // ─── Optional fields ───────────────────────────────────────────
* Text field with title case formatting.
* fieldType must be one of: name, nationality, streetAddress, city, function applyOptionalFieldRules(fieldType: FieldType) {
* pickupLocation, returnLocation, country 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) { export function textField(fieldType: FieldType) {
return baseTextField(fieldType, false) return applyFieldRules(fieldType)
} }
/** Optional version of textField */
export function optionalTextField(fieldType: FieldType) { export function optionalTextField(fieldType: FieldType) {
return baseTextField(fieldType, true) return applyOptionalFieldRules(fieldType)
} }
/**
* Title-case-all fields: fullAddress, commercialName, legalCompanyName
*/
export function titleCaseAllField(fieldType: FieldType) { export function titleCaseAllField(fieldType: FieldType) {
return baseTextField(fieldType, false) return applyFieldRules(fieldType)
} }
export function optionalTitleCaseAllField(fieldType: 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) { export function upperField(fieldType: FieldType) {
return baseTextField(fieldType, false) return applyFieldRules(fieldType)
} }
export function optionalUpperField(fieldType: 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) { export function carTextField(fieldType: FieldType) {
return baseTextField(fieldType, false) return applyFieldRules(fieldType)
} }
export function optionalCarTextField(fieldType: FieldType) { export function optionalCarTextField(fieldType: FieldType) {
return baseTextField(fieldType, true) return applyOptionalFieldRules(fieldType)
} }
/**
* Preserved format field: zipCode
*/
export function zipField() { export function zipField() {
return baseTextField('zipCode', false) return applyFieldRules('zipCode')
} }
export function optionalZipField() { export function optionalZipField() {
return baseTextField('zipCode', true) return applyOptionalFieldRules('zipCode')
} }
/** // ─── Email fields ──────────────────────────────────────────────
* Email field with full validation chain
*/
export function emailField() { export function emailField() {
return z return z
.string() .string()
.min(1, { message: 'Email is required' }) .min(1, { message: 'Email is required' })
.refine(globalCharRefine)
.trim() .trim()
.transform((val) => sanitizeAndFormat(val, 'email')) .transform((val: string) => sanitizeAndFormat(val, 'email'))
.refine(emailFormatRefine) .pipe(
z.string().refine(
(val: string) => EMAIL_REGEX.test(val),
{ message: 'Please enter a valid email address' }
)
)
} }
export function optionalEmailField() { export function optionalEmailField() {
@@ -150,9 +114,42 @@ export function optionalEmailField() {
if (val === undefined || val === null || val.trim() === '') return undefined if (val === undefined || val === null || val.trim() === '') return undefined
return sanitizeAndFormat(val, 'email') return sanitizeAndFormat(val, 'email')
}) })
.refine( .pipe(
(val) => val === undefined || emailFormatRefine(val as string) === true, z.string().optional().refine(
{ message: 'Please enter a valid email address' } (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' }
)
)
}
@@ -1,9 +1,10 @@
import { z } from 'zod' import { z } from 'zod'
import { optionalTextField, optionalTitleCaseAllField, optionalUpperField, optionalEmailField, optionalPhoneField } from '../../lib/zodValidation'
export const companySchema = z.object({ export const companySchema = z.object({
name: z.string().min(1).optional(), name: optionalTextField('commercialName'),
email: z.string().email().optional(), email: optionalEmailField(),
phone: z.string().optional(), phone: optionalPhoneField(),
address: z.record(z.unknown()).optional(), address: z.record(z.unknown()).optional(),
}) })
@@ -12,18 +13,18 @@ export const brandSchema = z.object({
tagline: z.string().optional(), tagline: z.string().optional(),
primaryColor: z.string().optional(), primaryColor: z.string().optional(),
accentColor: z.string().optional(), accentColor: z.string().optional(),
publicEmail: z.string().email().optional(), publicEmail: optionalEmailField(),
publicPhone: z.string().optional(), publicPhone: optionalPhoneField(),
publicAddress: z.string().optional(), publicAddress: z.string().optional(),
publicCity: z.string().optional(), publicCity: optionalTextField('city'),
publicCountry: z.string().optional(), publicCountry: optionalTextField('country'),
websiteUrl: z.string().url().optional(), websiteUrl: z.string().url().optional(),
whatsappNumber: z.string().optional(), whatsappNumber: z.string().optional(),
defaultLocale: z.string().optional(), defaultLocale: z.string().optional(),
defaultCurrency: z.literal('MAD').optional(), defaultCurrency: z.literal('MAD').optional(),
amanpayMerchantId: z.string().optional(), amanpayMerchantId: z.string().optional(),
amanpaySecretKey: z.string().optional(), amanpaySecretKey: z.string().optional(),
paypalEmail: z.string().email().optional(), paypalEmail: optionalEmailField(),
paypalMerchantId: z.string().optional(), paypalMerchantId: z.string().optional(),
isListedOnMarketplace: z.boolean().optional(), isListedOnMarketplace: z.boolean().optional(),
homePageConfig: z.object({ homePageConfig: z.object({
@@ -97,8 +98,8 @@ export const brandSchema = z.object({
}) })
export const contractSettingsSchema = z.object({ export const contractSettingsSchema = z.object({
legalName: z.string().optional(), legalName: optionalTitleCaseAllField('legalCompanyName'),
registrationNumber: z.string().optional(), registrationNumber: optionalUpperField('commercialRegistry'),
taxId: z.string().optional(), taxId: z.string().optional(),
terms: z.string().optional(), terms: z.string().optional(),
fuelPolicy: z.string().optional(), fuelPolicy: z.string().optional(),
@@ -147,8 +148,8 @@ export const accountingSettingsSchema = z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(), reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).optional(), fiscalYearStart: z.number().int().min(1).max(12).optional(),
currency: z.literal('MAD').optional(), currency: z.literal('MAD').optional(),
accountantEmail: z.string().email().optional(), accountantEmail: optionalEmailField(),
accountantName: z.string().optional(), accountantName: optionalTextField('name'),
autoSendReport: z.boolean().optional(), autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(), reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
}) })
@@ -1,4 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { textField, optionalTextField, emailField, optionalUpperField, optionalPhoneField } from '../../lib/zodValidation'
export const paginationSchema = z.object({ export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).max(10000).default(1), 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({ export const customerSchema = z.object({
firstName: z.string().min(1).max(100).trim(), firstName: textField('name'),
lastName: z.string().min(1).max(100).trim(), lastName: textField('name'),
email: z.string().email().max(255).trim().toLowerCase(), email: emailField(),
phone: z.string().max(30).trim().optional(), phone: optionalPhoneField(),
driverLicense: z.string().max(50).trim().optional(), driverLicense: optionalUpperField('driverLicenseNumber'),
dateOfBirth: z.string().datetime().optional(), dateOfBirth: z.string().datetime().optional(),
nationality: z.string().max(100).trim().optional(), nationality: optionalTextField('nationality'),
address: z.record(z.unknown()).optional(), address: z.record(z.unknown()).optional(),
notes: z.string().max(2000).trim().optional(), notes: z.string().max(2000).trim().optional(),
licenseExpiry: z.string().datetime().optional(), licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(), licenseIssuedAt: z.string().datetime().optional(),
licenseCountry: z.string().max(100).trim().optional(), licenseCountry: optionalTextField('country'),
licenseNumber: z.string().max(50).trim().optional(), licenseNumber: optionalUpperField('driverLicenseNumber'),
licenseCategory: z.string().max(20).trim().optional(), licenseCategory: optionalUpperField('licenseCategory'),
}) })
export const listQuerySchema = paginationSchema.extend({ export const listQuerySchema = paginationSchema.extend({
@@ -1,15 +1,16 @@
import { z } from 'zod' import { z } from 'zod'
import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalPhoneField } from '../../lib/zodValidation'
export const additionalDriverSchema = z.object({ export const additionalDriverSchema = z.object({
firstName: z.string().min(1), firstName: textField('name'),
lastName: z.string().min(1), lastName: textField('name'),
email: z.string().email().optional(), email: optionalEmailField(),
phone: z.string().optional(), phone: optionalPhoneField(),
driverLicense: z.string().min(1), driverLicense: upperField('driverLicenseNumber'),
licenseExpiry: z.string().datetime().optional(), licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(), licenseIssuedAt: z.string().datetime().optional(),
dateOfBirth: z.string().datetime().optional(), dateOfBirth: z.string().datetime().optional(),
nationality: z.string().optional(), nationality: optionalTextField('nationality'),
}) })
export const inspectionSchema = z.object({ export const inspectionSchema = z.object({
@@ -35,8 +36,8 @@ export const createSchema = z.object({
customerId: z.string().cuid(), customerId: z.string().cuid(),
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime(), endDate: z.string().datetime(),
pickupLocation: z.string().optional(), pickupLocation: optionalTextField('pickupLocation'),
returnLocation: z.string().optional(), returnLocation: optionalTextField('returnLocation'),
offerId: z.string().cuid().optional(), offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(), promoCodeUsed: z.string().optional(),
depositAmount: z.number().int().min(0).default(0), depositAmount: z.number().int().min(0).default(0),
@@ -49,8 +50,8 @@ export const createSchema = z.object({
export const updateSchema = z.object({ export const updateSchema = z.object({
startDate: z.string().datetime().optional(), startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(),
pickupLocation: z.string().optional().nullable(), pickupLocation: optionalTextField('pickupLocation').nullable(),
returnLocation: z.string().optional().nullable(), returnLocation: optionalTextField('returnLocation').nullable(),
depositAmount: z.number().int().min(0).optional(), depositAmount: z.number().int().min(0).optional(),
notes: z.string().optional().nullable(), notes: z.string().optional().nullable(),
paymentMode: z.string().max(50).optional().nullable(), paymentMode: z.string().max(50).optional().nullable(),
@@ -1,12 +1,14 @@
import { z } from 'zod' import { z } from 'zod'
import { sanitizeAndFormat } from '../../lib/inputValidation'
import { carTextField, upperField, optionalUpperField } from '../../lib/zodValidation'
export const vehicleSchema = z.object({ export const vehicleSchema = z.object({
make: z.string().min(1), make: carTextField('carMark'),
model: z.string().min(1), model: carTextField('carModel'),
year: z.number().int().min(1990).max(new Date().getFullYear() + 1), year: z.number().int().min(1990).max(new Date().getFullYear() + 1),
color: z.string().default(''), color: z.string().optional().default('').transform((v: string) => v ? sanitizeAndFormat(v, 'carColor') : ''),
licensePlate: z.string().min(1), licensePlate: upperField('licensePlate'),
vin: z.string().optional(), vin: optionalUpperField('vin'),
category: z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK']), category: z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK']),
seats: z.number().int().min(1).max(20).default(5), seats: z.number().int().min(1).max(20).default(5),
transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'), transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'),