add text input validation
Build & Push / Pipeline Tests (push) Failing after 1m5s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 52s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Push / Pipeline Tests (push) Failing after 1m5s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 52s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
This commit is contained in:
@@ -69,18 +69,18 @@ const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
|
||||
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 },
|
||||
city: { maxLength: 85, 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 },
|
||||
fullAddress: { maxLength: 255, 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
|
||||
email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
|
||||
email: { maxLength: 254, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
|
||||
|
||||
// Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
|
||||
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
|
||||
@@ -10,6 +10,113 @@ const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
|
||||
|
||||
/** 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}$/
|
||||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/
|
||||
const ALPHANUMERIC_REGEX = /^[A-Za-z0-9]+$/
|
||||
|
||||
export const languageSchema = z.enum(['en', 'fr', 'ar'])
|
||||
export type InputLanguage = z.infer<typeof languageSchema>
|
||||
|
||||
const LOCALIZED_TEXT_PATTERNS: Record<InputLanguage, RegExp> = {
|
||||
en: /^[A-Za-z\s'-]+$/u,
|
||||
fr: /^[A-Za-zÀ-ÖØ-öø-ÿŒœ\s'-]+$/u,
|
||||
ar: /^[\u0600-\u06FF\u0750-\u077F\s،؛؟ـ'-]+$/u,
|
||||
}
|
||||
const LOCALIZED_ADDRESS_PATTERNS: Record<InputLanguage, RegExp> = {
|
||||
en: /^[A-Za-z0-9\s,'-]+$/u,
|
||||
fr: /^[A-Za-z0-9À-ÖØ-öø-ÿŒœ\s,'-]+$/u,
|
||||
ar: /^[0-9\u0660-\u0669\u0600-\u06FF\u0750-\u077F\s،؛؟ـ,'-]+$/u,
|
||||
}
|
||||
|
||||
function normalizeNfc(value: string) {
|
||||
return value.trim().normalize('NFC')
|
||||
}
|
||||
|
||||
export function validateLocalizedText(value: string, language: InputLanguage, maxLength: number) {
|
||||
const normalized = normalizeNfc(value)
|
||||
return normalized.length >= 1 &&
|
||||
normalized.length <= maxLength &&
|
||||
LOCALIZED_TEXT_PATTERNS[language].test(normalized)
|
||||
}
|
||||
|
||||
export function validateLocalizedAddress(value: string, language: InputLanguage, maxLength: number) {
|
||||
const normalized = normalizeNfc(value)
|
||||
return normalized.length >= 1 &&
|
||||
normalized.length <= maxLength &&
|
||||
LOCALIZED_ADDRESS_PATTERNS[language].test(normalized)
|
||||
}
|
||||
|
||||
export function localizedTextIssue(field: string) {
|
||||
return `${field} contains characters that do not match the selected language`
|
||||
}
|
||||
|
||||
export function isoDateField() {
|
||||
return z.string().regex(ISO_DATE_REGEX, { message: 'Use YYYY-MM-DD format' })
|
||||
}
|
||||
|
||||
export function pastOrTodayIsoDateField() {
|
||||
return isoDateField().refine((value) => value <= new Date().toISOString().slice(0, 10), {
|
||||
message: 'Date cannot be in the future',
|
||||
})
|
||||
}
|
||||
|
||||
export function optionalVinField() {
|
||||
return z.string().optional().transform((value) => {
|
||||
if (value === undefined || value.trim() === '') return undefined
|
||||
return value.trim().toUpperCase()
|
||||
}).pipe(
|
||||
z.string().regex(VIN_REGEX, { message: 'VIN must be 17 characters and cannot contain I, O, or Q' }).optional()
|
||||
)
|
||||
}
|
||||
|
||||
export function vinField() {
|
||||
return z.string().trim().transform((value) => value.toUpperCase()).pipe(
|
||||
z.string().regex(VIN_REGEX, { message: 'VIN must be 17 characters and cannot contain I, O, or Q' })
|
||||
)
|
||||
}
|
||||
|
||||
export function optionalAlphanumericIdField() {
|
||||
return z.string().optional().transform((value) => {
|
||||
if (value === undefined || value.trim() === '') return undefined
|
||||
return value.trim().toUpperCase()
|
||||
}).pipe(
|
||||
z.string()
|
||||
.min(5, { message: 'Minimum 5 characters required' })
|
||||
.max(30, { message: 'Maximum 30 characters allowed' })
|
||||
.regex(ALPHANUMERIC_REGEX, { message: 'Only letters and numbers are allowed' })
|
||||
.optional()
|
||||
)
|
||||
}
|
||||
|
||||
export function requiredAlphanumericIdField() {
|
||||
return z.string()
|
||||
.trim()
|
||||
.min(5, { message: 'Minimum 5 characters required' })
|
||||
.max(30, { message: 'Maximum 30 characters allowed' })
|
||||
.regex(ALPHANUMERIC_REGEX, { message: 'Only letters and numbers are allowed' })
|
||||
.transform((value) => value.toUpperCase())
|
||||
}
|
||||
|
||||
export function countryCodeField() {
|
||||
return z.string()
|
||||
.trim()
|
||||
.length(2, { message: 'Use a valid ISO country code' })
|
||||
.transform((value) => value.toUpperCase())
|
||||
.refine((value) => {
|
||||
try {
|
||||
return new Intl.DisplayNames(['en'], { type: 'region' }).of(value) !== value
|
||||
} catch {
|
||||
return /^[A-Z]{2}$/.test(value)
|
||||
}
|
||||
}, { message: 'Use a valid ISO country code' })
|
||||
}
|
||||
|
||||
export function optionalCountryCodeField() {
|
||||
return z.string().optional().transform((value) => {
|
||||
if (value === undefined || value.trim() === '') return undefined
|
||||
return value.trim().toUpperCase()
|
||||
}).pipe(countryCodeField().optional())
|
||||
}
|
||||
|
||||
// ─── Phone sanitization ───────────────────────────────────────
|
||||
|
||||
@@ -21,6 +128,15 @@ function sanitizePhone(raw: string): string {
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
function normalizeMoroccanPhone(raw: string): string {
|
||||
const compact = sanitizePhone(raw).replace(/[\s().-]/g, '')
|
||||
if (!MA_PHONE_REGEX.test(sanitizePhone(raw))) return compact
|
||||
if (compact.startsWith('+212')) return compact
|
||||
if (compact.startsWith('00212')) return `+212${compact.slice(5)}`
|
||||
if (compact.startsWith('0')) return `+212${compact.slice(1)}`
|
||||
return compact
|
||||
}
|
||||
|
||||
// ─── Required fields ───────────────────────────────────────────
|
||||
|
||||
function applyFieldRules(fieldType: FieldType) {
|
||||
@@ -96,6 +212,7 @@ export function emailField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Email is required' })
|
||||
.max(254, { message: 'Maximum 254 characters allowed' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizeAndFormat(val, 'email'))
|
||||
.pipe(
|
||||
@@ -109,6 +226,7 @@ export function emailField() {
|
||||
export function optionalEmailField() {
|
||||
return z
|
||||
.string()
|
||||
.max(254, { message: 'Maximum 254 characters allowed' })
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
@@ -128,27 +246,27 @@ export function phoneField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Phone number is required' })
|
||||
.max(20, { message: 'Maximum 20 characters allowed' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizePhone(val))
|
||||
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' })
|
||||
.transform((val: string) => normalizeMoroccanPhone(val))
|
||||
.pipe(
|
||||
z.string().refine(
|
||||
(val: string) => MA_PHONE_REGEX.test(val),
|
||||
{ message: 'Please enter a valid Morocco phone number' }
|
||||
)
|
||||
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' })
|
||||
)
|
||||
}
|
||||
|
||||
export function optionalPhoneField() {
|
||||
return z
|
||||
.string()
|
||||
.max(20, { message: 'Maximum 20 characters allowed' })
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizePhone(val)
|
||||
return normalizeMoroccanPhone(val)
|
||||
})
|
||||
.pipe(
|
||||
z.string().optional().refine(
|
||||
(val) => val === undefined || MA_PHONE_REGEX.test(val),
|
||||
(val) => val === undefined || /^\+212[5-7]\d{8}$/.test(val),
|
||||
{ message: 'Please enter a valid Morocco phone number' }
|
||||
)
|
||||
)
|
||||
@@ -157,12 +275,26 @@ export function optionalPhoneField() {
|
||||
export function optionalContactPhoneField() {
|
||||
return z
|
||||
.string()
|
||||
.max(20, { message: 'Maximum 20 characters allowed' })
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizePhone(val)
|
||||
return normalizeMoroccanPhone(val)
|
||||
})
|
||||
.pipe(
|
||||
z.string().max(30, { message: 'Maximum 30 characters allowed' }).optional()
|
||||
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' }).optional()
|
||||
)
|
||||
}
|
||||
|
||||
export function contactPhoneField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Phone number is required' })
|
||||
.max(20, { message: 'Maximum 20 characters allowed' })
|
||||
.trim()
|
||||
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' })
|
||||
.transform((val: string) => normalizeMoroccanPhone(val))
|
||||
.pipe(
|
||||
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, countryCodeField, languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
import type {
|
||||
CarplaceHomepageContent,
|
||||
CarplaceHomepageHowItWorksStep,
|
||||
@@ -10,14 +11,14 @@ import type {
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
totpCode: z.string().length(6).optional(),
|
||||
recoveryCode: z.string().min(8).max(32).optional(),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
@@ -87,23 +88,38 @@ export const permissionSchema = z.object({
|
||||
})
|
||||
|
||||
export const createAdminSchema = z.object({
|
||||
email: z.string().email().trim().toLowerCase(),
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
preferredLocale: z.enum(['ar', 'en', 'fr']).default('en'),
|
||||
preferredLocale: languageSchema.default('en'),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (!validateLocalizedText(data.firstName, data.preferredLocale, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(data.lastName, data.preferredLocale, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const updateAdminSchema = z.object({
|
||||
email: z.string().email().trim().toLowerCase().optional(),
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
email: z.string().email().max(254).trim().toLowerCase().optional(),
|
||||
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(),
|
||||
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
|
||||
preferredLocale: z.enum(['ar', 'en', 'fr']).optional(),
|
||||
preferredLocale: languageSchema.optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
const language = data.preferredLocale ?? 'en'
|
||||
if (data.firstName && !validateLocalizedText(data.firstName, language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (data.lastName && !validateLocalizedText(data.lastName, language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const adminRoleSchema = z.object({
|
||||
@@ -120,7 +136,7 @@ export const companyStatusSchema = z.object({
|
||||
})
|
||||
|
||||
const nullableString = z.union([z.string(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email().max(254).trim().toLowerCase(), z.null()]).optional()
|
||||
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
|
||||
const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.null()]).optional()
|
||||
|
||||
@@ -133,13 +149,13 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase alphanumeric with optional hyphens')
|
||||
.max(50)
|
||||
.optional(),
|
||||
email: z.string().email().optional(), phone: nullableString,
|
||||
email: z.string().email().max(254).trim().toLowerCase().optional(), phone: z.union([contactPhoneField(), z.null()]).optional(),
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
address: z.object({
|
||||
streetAddress: nullableString,
|
||||
city: nullableString,
|
||||
country: nullableString,
|
||||
city: z.union([z.string().trim().max(85), z.null()]).optional(),
|
||||
country: z.union([countryCodeField(), z.null()]).optional(),
|
||||
zipCode: nullableString,
|
||||
legalName: nullableString,
|
||||
legalForm: nullableString,
|
||||
@@ -157,7 +173,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
responsibleRole: nullableString,
|
||||
responsibleIdentityNumber: nullableString,
|
||||
responsibleQualification: nullableString,
|
||||
responsiblePhone: nullableString,
|
||||
responsiblePhone: z.union([contactPhoneField(), z.null()]).optional(),
|
||||
responsibleEmail: nullableEmail,
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
@@ -173,9 +189,9 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
brand: z.object({
|
||||
displayName: z.string().min(1).optional(), tagline: nullableString,
|
||||
subdomain: z.string().min(1).optional(), customDomain: nullableString,
|
||||
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
|
||||
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
||||
publicEmail: nullableEmail, publicPhone: z.union([contactPhoneField(), z.null()]).optional(), publicAddress: z.union([z.string().trim().max(255), z.null()]).optional(),
|
||||
publicCity: z.union([z.string().trim().max(85), z.null()]).optional(), publicCountry: z.union([countryCodeField(), z.null()]).optional(), websiteUrl: nullableUrl,
|
||||
whatsappNumber: z.union([contactPhoneField(), z.null()]).optional(), defaultLocale: languageSchema.optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.any().optional(),
|
||||
@@ -247,7 +263,7 @@ export const invoiceIdParamSchema = z.object({
|
||||
|
||||
export const billingAccountUpdateSchema = z.object({
|
||||
legalName: z.string().min(1).max(255).optional(),
|
||||
billingEmail: z.string().email().optional(),
|
||||
billingEmail: z.string().email().max(254).trim().toLowerCase().optional(),
|
||||
billingAddress: z.any().optional(),
|
||||
taxId: z.union([z.string().max(120), z.null()]).optional(),
|
||||
taxExempt: z.boolean().optional(),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const summaryQuerySchema = z.object({
|
||||
period: z.string().default('30d'),
|
||||
period: z.string().trim().max(20).default('30d'),
|
||||
})
|
||||
|
||||
export const reportQuerySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
format: z.string().default('JSON'),
|
||||
period: z.string().optional(),
|
||||
from: z.string().trim().max(30).optional(),
|
||||
to: z.string().trim().max(30).optional(),
|
||||
format: z.enum(['JSON', 'CSV']).default('JSON'),
|
||||
period: z.string().trim().max(20).optional(),
|
||||
})
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
import { localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
/**
|
||||
* Minimal signup schema — only asks for what's needed to create an identity.
|
||||
* See progressive-signup-plan.md Section 3.
|
||||
*/
|
||||
export const accountStartSchema = z.object({
|
||||
firstName: z.string().trim().min(1).max(80),
|
||||
lastName: z.string().trim().min(1).max(80),
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
companyName: z.string().trim().min(2).max(120),
|
||||
email: z.string().email(),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
password: z.string().min(8).max(128),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
}).superRefine((account, ctx) => {
|
||||
if (!validateLocalizedText(account.firstName, account.preferredLanguage, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(account.lastName, account.preferredLanguage, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const companyProfileSchema = z.object({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, countryCodeField, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const companySignupSchema = z.object({
|
||||
firstName: z.string().min(1).max(80),
|
||||
lastName: z.string().min(1).max(80),
|
||||
email: z.string().email(),
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
password: z.string().min(8).max(128),
|
||||
companyName: z.string().min(2).max(120),
|
||||
legalName: z.string().min(2).max(160),
|
||||
@@ -15,10 +16,10 @@ export const companySignupSchema = z.object({
|
||||
operatingLicenseIssuedAt: z.string().min(1).max(40),
|
||||
operatingLicenseIssuedBy: z.string().min(1).max(160),
|
||||
streetAddress: z.string().min(1).max(200),
|
||||
city: z.string().min(1).max(120),
|
||||
country: z.string().min(1).max(120),
|
||||
city: z.string().trim().min(1).max(85).transform((value) => value.normalize('NFC')),
|
||||
country: countryCodeField(),
|
||||
zipCode: z.string().min(1).max(40),
|
||||
companyPhone: z.string().min(1).max(80),
|
||||
companyPhone: contactPhoneField(),
|
||||
companyEmail: z.string().email(),
|
||||
fax: z.string().max(80).optional(),
|
||||
yearsActive: z.string().min(1).max(80),
|
||||
@@ -28,10 +29,20 @@ export const companySignupSchema = z.object({
|
||||
responsibleRole: z.string().min(1).max(120),
|
||||
responsibleIdentityNumber: z.string().min(1).max(120),
|
||||
responsibleQualification: z.string().max(200).optional(),
|
||||
responsiblePhone: z.string().min(1).max(80),
|
||||
responsiblePhone: contactPhoneField(),
|
||||
responsibleEmail: z.string().email(),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.literal('MAD'),
|
||||
}).superRefine((signup, ctx) => {
|
||||
if (!validateLocalizedText(signup.firstName, signup.preferredLanguage, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(signup.lastName, signup.preferredLanguage, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
if (!validateLocalizedText(signup.city, signup.preferredLanguage, 85)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['city'], message: localizedTextIssue('City') })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const employeeLoginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
totpCode: z.string().length(6).optional(),
|
||||
})
|
||||
|
||||
export const employeeForgotPasswordSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
})
|
||||
|
||||
export const employeeLanguageSchema = z.object({
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const renterUpdateSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
firstName: z.string().trim().max(50).transform((value) => value.normalize('NFC')).optional(),
|
||||
lastName: z.string().trim().max(50).transform((value) => value.normalize('NFC')).optional(),
|
||||
phone: contactPhoneField().optional(),
|
||||
preferredLocale: languageSchema.optional(),
|
||||
preferredCurrency: z.literal('MAD').optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
const language = data.preferredLocale ?? 'fr'
|
||||
if (data.firstName && !validateLocalizedText(data.firstName, language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (data.lastName && !validateLocalizedText(data.lastName, language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const renterFcmTokenSchema = z.object({
|
||||
|
||||
@@ -27,7 +27,7 @@ export const manualBillingPaymentSchema = z.object({
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']),
|
||||
method: z.enum(['CHECK', 'BANK_TRANSFER']),
|
||||
receivedAt: z.string().datetime().optional(),
|
||||
receivedAt: z.string().datetime({ offset: true }).optional(),
|
||||
reference: z.string().trim().max(120).optional(),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
idempotencyKey: z.string().uuid(),
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function findVehicleForCarplaceById(vehicleId: string) {
|
||||
}
|
||||
|
||||
export async function upsertCarplaceCustomer(companyId: string, data: {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
@@ -96,6 +97,7 @@ export async function upsertCarplaceCustomer(companyId: string, data: {
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
language: data.language,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, localizedTextIssue, validateLocalizedAddress, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(100).default(1),
|
||||
@@ -42,17 +43,17 @@ export const carplaceQuoteSchema = z.object({
|
||||
export const carplaceReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100).optional(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
// Contact info — collected at reservation time
|
||||
phone: z.string().min(1).max(30).optional(),
|
||||
phone: contactPhoneField(),
|
||||
driverAge: z.coerce.number().int().min(18).max(100).optional(),
|
||||
// Identity & license — optional at reservation, collected at pickup
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
fullAddress: z.string().min(1).max(255).optional(),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
@@ -67,6 +68,16 @@ export const carplaceReservationSchema = z.object({
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
idempotencyKey: z.string().uuid().optional(),
|
||||
}).superRefine((reservation, ctx) => {
|
||||
if (!validateLocalizedText(reservation.firstName, reservation.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(reservation.lastName, reservation.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
if (reservation.fullAddress && !validateLocalizedAddress(reservation.fullAddress, reservation.language, 255)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['fullAddress'], message: localizedTextIssue('Address') })
|
||||
}
|
||||
})
|
||||
|
||||
export const carplaceFunnelEventSchema = z.object({
|
||||
|
||||
@@ -339,6 +339,7 @@ export async function createCarplaceReservation(body: {
|
||||
}
|
||||
|
||||
const customer = await repo.upsertCarplaceCustomer(vehicle.companyId, {
|
||||
language: (body.language ?? 'fr') as 'en' | 'fr' | 'ar',
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
import { optionalTextField, optionalTitleCaseAllField, optionalUpperField, optionalEmailField, optionalPhoneField } from '../../lib/zodValidation'
|
||||
import {
|
||||
contactPhoneField,
|
||||
countryCodeField,
|
||||
optionalEmailField,
|
||||
optionalPhoneField,
|
||||
optionalTextField,
|
||||
optionalTitleCaseAllField,
|
||||
optionalUpperField,
|
||||
} from '../../lib/zodValidation'
|
||||
|
||||
const nullableLabel = z.union([z.string().trim().max(80), z.null()]).optional()
|
||||
|
||||
export const companySchema = z.object({
|
||||
name: optionalTextField('commercialName'),
|
||||
@@ -9,34 +19,34 @@ export const companySchema = z.object({
|
||||
})
|
||||
|
||||
export const brandSchema = z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
tagline: z.string().optional(),
|
||||
displayName: z.string().trim().min(1).max(100).optional(),
|
||||
tagline: z.string().trim().max(160).optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
accentColor: z.string().optional(),
|
||||
publicEmail: optionalEmailField(),
|
||||
publicPhone: optionalPhoneField(),
|
||||
publicAddress: z.string().optional(),
|
||||
publicAddress: z.string().trim().max(255).optional(),
|
||||
publicCity: optionalTextField('city'),
|
||||
publicCountry: optionalTextField('country'),
|
||||
publicCountry: countryCodeField().optional(),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
whatsappNumber: z.string().optional(),
|
||||
whatsappNumber: contactPhoneField().optional(),
|
||||
defaultLocale: z.enum(['ar', 'en', 'fr']).optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
|
||||
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
|
||||
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
|
||||
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
|
||||
pricingTitle: z.union([z.string(), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string(), z.null()]).optional(),
|
||||
heroTitle: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string().trim().max(500), z.null()]).optional(),
|
||||
viewOffersLabel: nullableLabel,
|
||||
viewPricingLabel: nullableLabel,
|
||||
contactCompanyLabel: nullableLabel,
|
||||
activeOffersTitle: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
seeAllOffersLabel: nullableLabel,
|
||||
noActiveOffersLabel: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
viewVehicleLabel: nullableLabel,
|
||||
pricingEyebrow: nullableLabel,
|
||||
pricingTitle: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string().trim().max(500), z.null()]).optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
@@ -52,14 +62,14 @@ export const brandSchema = z.object({
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
menuConfig: z.object({
|
||||
aboutLabel: z.union([z.string(), z.null()]).optional(),
|
||||
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
|
||||
offersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
blogLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactLabel: z.union([z.string(), z.null()]).optional(),
|
||||
bookCarLabel: z.union([z.string(), z.null()]).optional(),
|
||||
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
|
||||
aboutLabel: nullableLabel,
|
||||
vehiclesLabel: nullableLabel,
|
||||
offersLabel: nullableLabel,
|
||||
pricingLabel: nullableLabel,
|
||||
blogLabel: nullableLabel,
|
||||
contactLabel: nullableLabel,
|
||||
bookCarLabel: nullableLabel,
|
||||
siteNavigationLabel: nullableLabel,
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
@@ -96,20 +106,20 @@ export const brandSchema = z.object({
|
||||
export const contractSettingsSchema = z.object({
|
||||
legalName: optionalTitleCaseAllField('legalCompanyName'),
|
||||
registrationNumber: optionalUpperField('commercialRegistry'),
|
||||
taxId: z.string().optional(),
|
||||
terms: z.string().optional(),
|
||||
fuelPolicy: z.string().optional(),
|
||||
depositPolicy: z.string().optional(),
|
||||
lateFeePolicy: z.string().optional(),
|
||||
damagePolicy: z.string().optional(),
|
||||
additionalDriverPolicy: z.string().optional(),
|
||||
contractFooterNote: z.string().optional(),
|
||||
invoiceFooterNote: z.string().optional(),
|
||||
taxId: z.string().trim().max(30).optional(),
|
||||
terms: z.string().trim().max(4000).optional(),
|
||||
fuelPolicy: z.string().trim().max(4000).optional(),
|
||||
depositPolicy: z.string().trim().max(2000).optional(),
|
||||
lateFeePolicy: z.string().trim().max(2000).optional(),
|
||||
damagePolicy: z.string().trim().max(4000).optional(),
|
||||
additionalDriverPolicy: z.string().trim().max(4000).optional(),
|
||||
contractFooterNote: z.string().trim().max(500).optional(),
|
||||
invoiceFooterNote: z.string().trim().max(500).optional(),
|
||||
signatureRequired: z.boolean().optional(),
|
||||
showTax: z.boolean().optional(),
|
||||
taxLabel: z.string().optional(),
|
||||
taxLabel: z.string().trim().max(80).optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
fuelPolicyNote: z.string().optional(),
|
||||
fuelPolicyNote: z.string().trim().max(1000).optional(),
|
||||
fuelChargePerLiter: z.number().int().optional(),
|
||||
fuelShortfallFee: z.number().int().optional(),
|
||||
lateFeePerHour: z.number().int().optional(),
|
||||
@@ -119,8 +129,8 @@ export const contractSettingsSchema = z.object({
|
||||
})
|
||||
|
||||
export const insurancePolicySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']),
|
||||
chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']),
|
||||
chargeValue: z.number().int().min(0),
|
||||
@@ -130,14 +140,14 @@ export const insurancePolicySchema = z.object({
|
||||
})
|
||||
|
||||
export const pricingRuleSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
type: z.enum(['SURCHARGE', 'DISCOUNT']),
|
||||
condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']),
|
||||
conditionValue: z.number().int(),
|
||||
adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']),
|
||||
adjustmentValue: z.number().int(),
|
||||
isActive: z.boolean().default(true),
|
||||
description: z.string().optional(),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
})
|
||||
|
||||
export const accountingSettingsSchema = z.object({
|
||||
|
||||
@@ -15,8 +15,8 @@ export const createSchema = z.object({
|
||||
customerId: z.string().optional(),
|
||||
severity: z.enum(SEVERITIES).default('LEVEL_1'),
|
||||
category: z.enum(CATEGORIES),
|
||||
subject: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
subject: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -24,10 +24,10 @@ export const updateSchema = z.object({
|
||||
status: z.enum(STATUSES).optional(),
|
||||
severity: z.enum(SEVERITIES).optional(),
|
||||
category: z.enum(CATEGORIES).optional(),
|
||||
subject: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
resolution: z.string().optional(),
|
||||
subject: z.string().trim().min(1).max(120).optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
notes: z.string().trim().max(2000).optional(),
|
||||
resolution: z.string().trim().max(2000).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ function buildCustomerSelect(includeLicenseImageUrl: boolean) {
|
||||
id: true,
|
||||
companyId: true,
|
||||
renterId: true,
|
||||
language: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||
import * as service from './customer.service'
|
||||
import { customerSchema, listQuerySchema, approveLicenseSchema, flagSchema, idParamSchema } from './customer.schemas'
|
||||
import { customerSchema, customerUpdateSchema, listQuerySchema, approveLicenseSchema, flagSchema, idParamSchema } from './customer.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -47,7 +47,7 @@ router.get('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(customerSchema.partial(), req)
|
||||
const body = parseBody(customerUpdateSchema, req)
|
||||
const customer = await service.updateCustomer(id, req.companyId, body)
|
||||
ok(res, customer)
|
||||
} catch (err) { next(err) }
|
||||
|
||||
@@ -1,28 +1,60 @@
|
||||
import { z } from 'zod'
|
||||
import { textField, optionalTextField, emailField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation'
|
||||
import {
|
||||
countryCodeField,
|
||||
contactPhoneField,
|
||||
emailField,
|
||||
languageSchema,
|
||||
localizedTextIssue,
|
||||
optionalAlphanumericIdField,
|
||||
optionalCountryCodeField,
|
||||
validateLocalizedAddress,
|
||||
validateLocalizedText,
|
||||
} from '../../lib/zodValidation'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const customerSchema = z.object({
|
||||
firstName: textField('name'),
|
||||
lastName: textField('name'),
|
||||
const customerBaseSchema = z.object({
|
||||
language: languageSchema,
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: emailField(),
|
||||
phone: optionalContactPhoneField(),
|
||||
driverLicense: optionalUpperField('driverLicenseNumber'),
|
||||
phone: contactPhoneField(),
|
||||
driverLicense: optionalAlphanumericIdField(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: optionalTextField('nationality'),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
nationality: countryCodeField(),
|
||||
address: z.record(z.unknown()),
|
||||
notes: z.string().max(2000).trim().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: optionalTextField('country'),
|
||||
licenseNumber: optionalUpperField('driverLicenseNumber'),
|
||||
licenseCategory: optionalUpperField('licenseCategory'),
|
||||
licenseCountry: optionalCountryCodeField(),
|
||||
licenseNumber: optionalAlphanumericIdField(),
|
||||
licenseCategory: z.string().trim().min(1).max(30).optional(),
|
||||
})
|
||||
|
||||
function validateCustomerLanguageFields(customer: Partial<z.infer<typeof customerBaseSchema>>, ctx: z.RefinementCtx) {
|
||||
if (!customer.language) return
|
||||
if (customer.firstName !== undefined && !validateLocalizedText(customer.firstName, customer.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (customer.lastName !== undefined && !validateLocalizedText(customer.lastName, customer.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
const address = customer.address
|
||||
if (address !== undefined) {
|
||||
if (typeof address.fullAddress !== 'string') {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['address', 'fullAddress'], message: 'Address is required' })
|
||||
} else if (!validateLocalizedAddress(address.fullAddress, customer.language, 255)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['address', 'fullAddress'], message: localizedTextIssue('Address') })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const customerSchema = customerBaseSchema.superRefine(validateCustomerLanguageFields)
|
||||
export const customerUpdateSchema = customerBaseSchema.partial().superRefine(validateCustomerLanguageFields)
|
||||
|
||||
export const listQuerySchema = paginationSchema.extend({
|
||||
q: z.string().max(100).optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import { requiredAlphanumericIdField } from '../../lib/zodValidation'
|
||||
|
||||
/** Request body for POST /api/v1/licenses/validate */
|
||||
export const validateLicenseSchema = z.object({
|
||||
expiration_date: z.string().min(1, 'expiration_date is required'),
|
||||
license_number: z.string().max(100).optional(),
|
||||
license_number: requiredAlphanumericIdField().optional(),
|
||||
state: z.string().length(2).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const offerSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
termsAndConds: z.string().optional(),
|
||||
title: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
termsAndConds: z.string().trim().max(2000).optional(),
|
||||
type: z.enum(['PERCENTAGE', 'FIXED_AMOUNT', 'FREE_DAY', 'SPECIAL_RATE']),
|
||||
discountValue: z.number().int().min(0),
|
||||
specialRate: z.number().int().optional(),
|
||||
@@ -11,7 +11,7 @@ export const offerSchema = z.object({
|
||||
categories: z.array(z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'])).default([]),
|
||||
minRentalDays: z.number().int().optional(),
|
||||
maxRentalDays: z.number().int().optional(),
|
||||
promoCode: z.string().optional(),
|
||||
promoCode: z.string().trim().max(30).regex(/^[A-Z0-9_-]+$/, 'Code must be uppercase A-Z, 0-9, _ or -').optional(),
|
||||
maxRedemptions: z.number().int().optional(),
|
||||
validFrom: z.string().datetime(),
|
||||
validUntil: z.string().datetime(),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const manualPaymentSchema = z.object({
|
||||
|
||||
export const refundSchema = z.object({
|
||||
amount: z.number().int().positive().optional(),
|
||||
reason: z.string().optional(),
|
||||
reason: z.string().trim().max(500).optional(),
|
||||
})
|
||||
|
||||
export const reservationParamSchema = z.object({
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation'
|
||||
import {
|
||||
emailField,
|
||||
languageSchema,
|
||||
localizedTextIssue,
|
||||
optionalAlphanumericIdField,
|
||||
optionalContactPhoneField,
|
||||
optionalCountryCodeField,
|
||||
validateLocalizedText,
|
||||
} from '../../lib/zodValidation'
|
||||
|
||||
const rentalPaymentModeSchema = z.enum(['BANK_TRANSFER', 'CHECK'])
|
||||
|
||||
export const additionalDriverSchema = z.object({
|
||||
firstName: textField('name'),
|
||||
lastName: textField('name'),
|
||||
email: optionalEmailField(),
|
||||
language: languageSchema,
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: emailField().optional(),
|
||||
phone: optionalContactPhoneField(),
|
||||
driverLicense: upperField('driverLicenseNumber'),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: optionalTextField('nationality'),
|
||||
driverLicense: optionalAlphanumericIdField().refine((value) => value !== undefined, { message: 'This field is required' }),
|
||||
licenseExpiry: z.string().datetime({ offset: true }).optional(),
|
||||
licenseIssuedAt: z.string().datetime({ offset: true }).optional(),
|
||||
dateOfBirth: z.string().datetime({ offset: true }).optional(),
|
||||
nationality: optionalCountryCodeField(),
|
||||
}).superRefine((driver, ctx) => {
|
||||
if (!validateLocalizedText(driver.firstName, driver.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(driver.lastName, driver.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
if (driver.licenseIssuedAt && driver.licenseExpiry && new Date(driver.licenseExpiry) <= new Date(driver.licenseIssuedAt)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['licenseExpiry'], message: 'Expiry date must be after issue date' })
|
||||
}
|
||||
})
|
||||
|
||||
export const inspectionSchema = z.object({
|
||||
@@ -36,10 +55,10 @@ export const inspectionSchema = z.object({
|
||||
export const createSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
customerId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: optionalTextField('pickupLocation'),
|
||||
returnLocation: optionalTextField('returnLocation'),
|
||||
startDate: z.string().datetime({ offset: true }),
|
||||
endDate: z.string().datetime({ offset: true }),
|
||||
pickupLocation: z.string().trim().min(1).max(255).optional(),
|
||||
returnLocation: z.string().trim().min(1).max(255).optional(),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
depositAmount: z.number().int().min(0).default(0),
|
||||
@@ -50,15 +69,19 @@ export const createSchema = z.object({
|
||||
contractFields: z.record(z.string().max(120), z.string().max(500).nullable()).optional(),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
||||
}).superRefine((reservation, ctx) => {
|
||||
if (new Date(reservation.endDate) <= new Date(reservation.startDate)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['endDate'], message: 'End date must be after start date' })
|
||||
}
|
||||
})
|
||||
|
||||
export const contractFieldsSchema = z.record(z.string().max(120), z.string().max(500).nullable()).optional()
|
||||
|
||||
export const updateSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
pickupLocation: optionalTextField('pickupLocation').nullable(),
|
||||
returnLocation: optionalTextField('returnLocation').nullable(),
|
||||
startDate: z.string().datetime({ offset: true }).optional(),
|
||||
endDate: z.string().datetime({ offset: true }).optional(),
|
||||
pickupLocation: z.string().trim().min(1).max(255).optional().nullable(),
|
||||
returnLocation: z.string().trim().min(1).max(255).optional().nullable(),
|
||||
depositAmount: z.number().int().min(0).optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
paymentMode: rentalPaymentModeSchema.optional().nullable(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const replySchema = z.object({
|
||||
companyReply: z.string().min(1),
|
||||
companyReply: z.string().trim().min(1).max(2000),
|
||||
})
|
||||
|
||||
export const listQuerySchema = z.object({
|
||||
|
||||
@@ -41,6 +41,7 @@ export async function findOfferByPromoCode(companyId: string, code: string) {
|
||||
}
|
||||
|
||||
export async function upsertCustomer(companyId: string, data: {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
@@ -79,6 +80,7 @@ export async function upsertCustomer(companyId: string, data: {
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
language: data.language,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: normalizedEmail,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, localizedTextIssue, validateLocalizedAddress, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const slugParamSchema = z.object({ slug: z.string() })
|
||||
export const bookingParamSchema = z.object({ slug: z.string(), id: z.string() })
|
||||
@@ -15,10 +16,11 @@ export const bookSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(1).max(30),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
phone: contactPhoneField(),
|
||||
pickupLocation: z.string().trim().min(1).max(100),
|
||||
returnLocation: z.string().trim().min(1).max(100),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
@@ -27,7 +29,7 @@ export const bookSchema = z.object({
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
fullAddress: z.string().min(1).max(255).optional(),
|
||||
licenseCountry: z.string().min(1).max(100).optional(),
|
||||
licenseNumber: z.string().min(1).max(50).optional(),
|
||||
licenseCategory: z.string().min(1).max(20).optional(),
|
||||
@@ -48,6 +50,16 @@ export const bookSchema = z.object({
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
})).default([]),
|
||||
}).superRefine((booking, ctx) => {
|
||||
if (!validateLocalizedText(booking.firstName, booking.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(booking.lastName, booking.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
if (booking.fullAddress && !validateLocalizedAddress(booking.fullAddress, booking.language, 255)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['fullAddress'], message: localizedTextIssue('Address') })
|
||||
}
|
||||
})
|
||||
|
||||
export const contactSchema = z.object({
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function createBooking(slug: string, body: {
|
||||
}
|
||||
|
||||
const customer = await repo.upsertCustomer(company.id, {
|
||||
language: body.language ?? 'fr',
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const startTrialSchema = z.object({
|
||||
|
||||
export const cancelSchema = z.object({
|
||||
mode: z.enum(['period_end', 'immediate']).default('period_end'),
|
||||
reason: z.string().max(500).optional(),
|
||||
reason: z.string().trim().max(500).optional(),
|
||||
})
|
||||
|
||||
export const manualCheckoutSchema = z.object({
|
||||
@@ -56,7 +56,7 @@ export const manualPaymentDocumentFieldsSchema = z.object({
|
||||
export const billingContactInputSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
employeeId: z.string().min(1).nullable().optional(),
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
locale: localeEnum.nullable().optional(),
|
||||
isPrimary: z.boolean(),
|
||||
receivePaymentNotices: z.boolean().default(true),
|
||||
@@ -98,7 +98,7 @@ export const acceptUpgradeQuoteSchema = z.object({
|
||||
})
|
||||
|
||||
export const cancelUpgradeRequestSchema = z.object({
|
||||
reason: z.string().max(500).optional(),
|
||||
reason: z.string().trim().max(500).optional(),
|
||||
})
|
||||
|
||||
export { manualMethodEnum, localeEnum, referenceSchema }
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
import { languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const inviteSchema = z.object({
|
||||
firstName: z.string().min(1).max(64),
|
||||
lastName: z.string().min(1).max(64),
|
||||
email: z.string().email(),
|
||||
language: languageSchema,
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
role: z.enum(['MANAGER', 'AGENT']),
|
||||
}).superRefine((invite, ctx) => {
|
||||
if (!validateLocalizedText(invite.firstName, invite.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(invite.lastName, invite.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const roleSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { sanitizeAndFormat } from '../../lib/inputValidation'
|
||||
import { carTextField, upperField, optionalUpperField } from '../../lib/zodValidation'
|
||||
import { carTextField, upperField, vinField } from '../../lib/zodValidation'
|
||||
|
||||
export const vehicleSchema = z.object({
|
||||
make: carTextField('carMark'),
|
||||
@@ -8,7 +8,7 @@ export const vehicleSchema = z.object({
|
||||
year: z.number().int().min(1990).max(new Date().getFullYear() + 1),
|
||||
color: z.string().optional().default('').transform((v: string) => v ? sanitizeAndFormat(v, 'carColor') : ''),
|
||||
licensePlate: upperField('licensePlate'),
|
||||
vin: optionalUpperField('vin'),
|
||||
vin: vinField(),
|
||||
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'),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { validateLicense } from './licenseValidationService'
|
||||
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
|
||||
|
||||
export interface AdditionalDriverInput {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
firstName: string
|
||||
lastName: string
|
||||
email?: string
|
||||
@@ -61,6 +62,7 @@ export async function applyAdditionalDriversToReservation(
|
||||
return {
|
||||
reservationId,
|
||||
companyId,
|
||||
language: driver.language,
|
||||
firstName: driver.firstName,
|
||||
lastName: driver.lastName,
|
||||
email: driver.email ?? null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { hashPublicAccessToken } from '../security/publicAccessTokens'
|
||||
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
|
||||
|
||||
export interface InvitePayload {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
@@ -170,7 +171,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const tokenHash = hashPublicAccessToken(rawToken)
|
||||
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
|
||||
const locale = coerceNotificationLocale(payload.language)
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user