reservation implementation
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* User Input Validation and Formatting Library
|
||||
*
|
||||
* Implements the rules defined in docs/input_validation_plan.md.
|
||||
* Provides formatting functions and a Zod-integrated pipeline that:
|
||||
* 1. Removes disallowed characters
|
||||
* 2. Trims leading/trailing whitespace
|
||||
* 3. Validates format (email)
|
||||
* 4. Applies case transformation
|
||||
* 5. Truncates to max length
|
||||
*/
|
||||
|
||||
// ─── 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_SPACES_HYPHEN = 'a-zA-Z\\s\\-'
|
||||
|
||||
/** Letters, numbers, spaces, hyphen, slash, comma (full address / company names) */
|
||||
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'
|
||||
| 'email'
|
||||
| 'licensePlate'
|
||||
| 'vin'
|
||||
| 'cin'
|
||||
| 'passportNumber'
|
||||
| 'internationalPermitNumber'
|
||||
| 'driverLicenseNumber'
|
||||
| 'licenseCategory'
|
||||
| 'iceNumber'
|
||||
| 'commercialRegistry'
|
||||
| 'carMark'
|
||||
| 'carModel'
|
||||
| 'carColor'
|
||||
| 'zipCode'
|
||||
|
||||
interface FieldConfig {
|
||||
maxLength: number
|
||||
allowedPattern: RegExp
|
||||
transform: (value: string) => string
|
||||
}
|
||||
|
||||
// ─── Formatting functions ──────────────────────────────────────
|
||||
|
||||
/** Uppercase first letter of each word, lowercase the rest */
|
||||
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) */
|
||||
export function toTitleCaseAll(str: string): string {
|
||||
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
export function toLowerCase(str: string): string {
|
||||
return str.toLowerCase()
|
||||
}
|
||||
|
||||
/** Uppercase letters only, leave numbers unchanged */
|
||||
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
|
||||
}
|
||||
|
||||
// ─── Field configuration ───────────────────────────────────────
|
||||
|
||||
const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
|
||||
// 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 },
|
||||
|
||||
// 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 },
|
||||
|
||||
// Group 3: Lowercase — email handled separately with format validation
|
||||
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 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 },
|
||||
|
||||
// Group 6: Preserved
|
||||
zipCode: { maxLength: 10, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\-]*$`), transform: preserveOriginal },
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────
|
||||
|
||||
/** Full processing pipeline: sanitize → trim → format → truncate */
|
||||
export function sanitizeAndFormat(value: string, fieldType: FieldType): string {
|
||||
const config = FIELD_CONFIGS[fieldType]
|
||||
|
||||
// Step 1: Remove disallowed characters
|
||||
let sanitized = ''
|
||||
for (const char of value) {
|
||||
if (config.allowedPattern.test(char)) {
|
||||
sanitized += char
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Trim
|
||||
sanitized = sanitized.trim()
|
||||
|
||||
// Step 3: Apply case transformation
|
||||
const formatted = config.transform(sanitized)
|
||||
|
||||
// Step 4: Truncate
|
||||
if (formatted.length > config.maxLength) {
|
||||
return formatted.slice(0, config.maxLength)
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/** Get the max length for a given field type */
|
||||
export function getMaxLength(fieldType: FieldType): number {
|
||||
return FIELD_CONFIGS[fieldType].maxLength
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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) ──────────────────────
|
||||
|
||||
/** 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' }
|
||||
|
||||
// ─── Pre-built Zod string wrappers ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const maxLength = getMaxLength(fieldType)
|
||||
let schema = 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' }
|
||||
)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Text field with title case formatting.
|
||||
* fieldType must be one of: name, nationality, streetAddress, city,
|
||||
* pickupLocation, returnLocation, country
|
||||
*/
|
||||
export function textField(fieldType: FieldType) {
|
||||
return baseTextField(fieldType, false)
|
||||
}
|
||||
|
||||
/** Optional version of textField */
|
||||
export function optionalTextField(fieldType: FieldType) {
|
||||
return baseTextField(fieldType, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-case-all fields: fullAddress, commercialName, legalCompanyName
|
||||
*/
|
||||
export function titleCaseAllField(fieldType: FieldType) {
|
||||
return baseTextField(fieldType, false)
|
||||
}
|
||||
|
||||
export function optionalTitleCaseAllField(fieldType: FieldType) {
|
||||
return baseTextField(fieldType, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
export function optionalUpperField(fieldType: FieldType) {
|
||||
return baseTextField(fieldType, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Car fields (title case, letters/numbers/spaces/hyphen allowed):
|
||||
* carMark, carModel, carColor
|
||||
*/
|
||||
export function carTextField(fieldType: FieldType) {
|
||||
return baseTextField(fieldType, false)
|
||||
}
|
||||
|
||||
export function optionalCarTextField(fieldType: FieldType) {
|
||||
return baseTextField(fieldType, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserved format field: zipCode
|
||||
*/
|
||||
export function zipField() {
|
||||
return baseTextField('zipCode', false)
|
||||
}
|
||||
|
||||
export function optionalZipField() {
|
||||
return baseTextField('zipCode', true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Email field with full validation chain
|
||||
*/
|
||||
export function emailField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Email is required' })
|
||||
.refine(globalCharRefine)
|
||||
.trim()
|
||||
.transform((val) => sanitizeAndFormat(val, 'email'))
|
||||
.refine(emailFormatRefine)
|
||||
}
|
||||
|
||||
export function optionalEmailField() {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
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' }
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user