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' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -48,6 +48,17 @@ export async function findVehicleForMarketplace(vehicleId: string, companySlug:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findVehicleForMarketplaceById(vehicleId: string) {
|
||||||
|
return prisma.vehicle.findFirst({
|
||||||
|
where: {
|
||||||
|
id: vehicleId,
|
||||||
|
isPublished: true,
|
||||||
|
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
||||||
|
},
|
||||||
|
include: { company: { include: { brand: true } } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||||
email: string
|
email: string
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -107,13 +118,45 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
|
|||||||
export async function createMarketplaceReservation(data: {
|
export async function createMarketplaceReservation(data: {
|
||||||
companyId: string; vehicleId: string; customerId: string
|
companyId: string; vehicleId: string; customerId: string
|
||||||
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
||||||
dailyRate: number; totalDays: number; totalAmount: number; notes?: string
|
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
|
||||||
}) {
|
}) {
|
||||||
return prisma.reservation.create({
|
return prisma.reservation.create({
|
||||||
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
|
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createMarketplaceFunnelEvent(data: {
|
||||||
|
eventName: string
|
||||||
|
companySlug: string
|
||||||
|
vehicleId: string
|
||||||
|
renterId?: string | null
|
||||||
|
sessionId?: string
|
||||||
|
path?: string
|
||||||
|
metadata?: Record<string, string | number | boolean | null>
|
||||||
|
}) {
|
||||||
|
const vehicle = await prisma.vehicle.findFirst({
|
||||||
|
where: {
|
||||||
|
id: data.vehicleId,
|
||||||
|
isPublished: true,
|
||||||
|
company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||||
|
},
|
||||||
|
select: { companyId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return prisma.marketplaceFunnelEvent.create({
|
||||||
|
data: {
|
||||||
|
eventName: data.eventName,
|
||||||
|
companyId: vehicle?.companyId ?? null,
|
||||||
|
companySlug: data.companySlug,
|
||||||
|
vehicleId: data.vehicleId,
|
||||||
|
renterId: data.renterId ?? null,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
path: data.path,
|
||||||
|
metadata: (data.metadata ?? {}) as any,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function findCompanyPage(slug: string) {
|
export async function findCompanyPage(slug: string) {
|
||||||
return prisma.company.findFirst({
|
return prisma.company.findFirst({
|
||||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as service from './marketplace.service'
|
|||||||
import {
|
import {
|
||||||
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
|
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
|
||||||
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
|
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
|
||||||
|
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
|
||||||
} from './marketplace.schemas'
|
} from './marketplace.schemas'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -65,6 +66,18 @@ router.post('/reservations', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/events', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = parseBody(marketplaceFunnelEventSchema, req)
|
||||||
|
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId))
|
||||||
|
} catch (err) {
|
||||||
|
if (isDatabaseUnavailableError(err)) {
|
||||||
|
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 })
|
||||||
|
}
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/review/:token', async (req, res, next) => {
|
router.get('/review/:token', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { token } = parseParams(reviewTokenSchema, req)
|
const { token } = parseParams(reviewTokenSchema, req)
|
||||||
@@ -137,6 +150,19 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||||
|
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
|
||||||
|
ok(res, await service.getMarketplaceVehicleAvailability(slug, id, { startDate, endDate }))
|
||||||
|
} catch (err) {
|
||||||
|
if (isDatabaseUnavailableError(err)) {
|
||||||
|
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
||||||
|
}
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/:slug/offers', async (req, res, next) => {
|
router.get('/:slug/offers', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { slug } = parseParams(slugParamSchema, req)
|
const { slug } = parseParams(slugParamSchema, req)
|
||||||
|
|||||||
@@ -24,9 +24,14 @@ export const companiesQuerySchema = z.object({
|
|||||||
hasOffer: z.string().optional(),
|
hasOffer: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const vehicleAvailabilityQuerySchema = z.object({
|
||||||
|
startDate: z.string().datetime(),
|
||||||
|
endDate: z.string().datetime(),
|
||||||
|
})
|
||||||
|
|
||||||
export const marketplaceReservationSchema = z.object({
|
export const marketplaceReservationSchema = z.object({
|
||||||
vehicleId: z.string().cuid(),
|
vehicleId: z.string().cuid(),
|
||||||
companySlug: z.string().trim().max(100),
|
companySlug: z.string().trim().max(100).optional(),
|
||||||
firstName: z.string().min(1).max(100),
|
firstName: z.string().min(1).max(100),
|
||||||
lastName: z.string().min(1).max(100),
|
lastName: z.string().min(1).max(100),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -51,6 +56,23 @@ export const marketplaceReservationSchema = z.object({
|
|||||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const marketplaceFunnelEventSchema = z.object({
|
||||||
|
eventName: z.enum([
|
||||||
|
'booking_form_viewed',
|
||||||
|
'trip_dates_selected',
|
||||||
|
'contact_details_started',
|
||||||
|
'driver_details_expanded',
|
||||||
|
'booking_request_submitted',
|
||||||
|
'booking_request_failed',
|
||||||
|
'booking_request_success',
|
||||||
|
]),
|
||||||
|
companySlug: z.string().trim().max(100),
|
||||||
|
vehicleId: z.string().cuid(),
|
||||||
|
sessionId: z.string().trim().max(100).optional(),
|
||||||
|
path: z.string().trim().max(500).optional(),
|
||||||
|
metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const reviewBodySchema = z.object({
|
export const reviewBodySchema = z.object({
|
||||||
overallRating: z.number().int().min(1).max(5),
|
overallRating: z.number().int().min(1).max(5),
|
||||||
vehicleRating: z.number().int().min(1).max(5).optional(),
|
vehicleRating: z.number().int().min(1).max(5).optional(),
|
||||||
|
|||||||
@@ -120,8 +120,42 @@ export async function searchVehicles(params: {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMarketplaceVehicleAvailability(
|
||||||
|
slug: string,
|
||||||
|
vehicleId: string,
|
||||||
|
params: { startDate: string; endDate: string },
|
||||||
|
) {
|
||||||
|
const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug)
|
||||||
|
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||||
|
|
||||||
|
const startDate = new Date(params.startDate)
|
||||||
|
const endDate = new Date(params.endDate)
|
||||||
|
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||||
|
companyId: vehicle.companyId,
|
||||||
|
range: { startDate, endDate },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: availability.available,
|
||||||
|
availabilityStatus: availability.status,
|
||||||
|
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||||
|
blockingReason: availability.blockingReason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateBookingReference() {
|
||||||
|
const year = new Date().getUTCFullYear()
|
||||||
|
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||||
|
return `BK-${year}-${random}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMarketplaceReservation(body: {
|
export async function createMarketplaceReservation(body: {
|
||||||
vehicleId: string; companySlug: string; firstName: string; lastName: string
|
vehicleId: string; companySlug?: string; firstName: string; lastName: string
|
||||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
||||||
identityDocumentNumber?: string; fullAddress?: string
|
identityDocumentNumber?: string; fullAddress?: string
|
||||||
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
|
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
|
||||||
@@ -136,7 +170,7 @@ export async function createMarketplaceReservation(body: {
|
|||||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||||
}
|
}
|
||||||
|
|
||||||
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
|
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
|
||||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||||
|
|
||||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||||
@@ -185,8 +219,18 @@ export async function createMarketplaceReservation(body: {
|
|||||||
const totalAmount = vehicle.dailyRate * totalDays
|
const totalAmount = vehicle.dailyRate * totalDays
|
||||||
|
|
||||||
const reservation = await repo.createMarketplaceReservation({
|
const reservation = await repo.createMarketplaceReservation({
|
||||||
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
|
companyId: vehicle.companyId,
|
||||||
startDate, endDate, pickupLocation, returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
|
vehicleId: body.vehicleId,
|
||||||
|
customerId: customer.id,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
pickupLocation,
|
||||||
|
returnLocation,
|
||||||
|
dailyRate: vehicle.dailyRate,
|
||||||
|
totalDays,
|
||||||
|
totalAmount,
|
||||||
|
notes: body.notes,
|
||||||
|
bookingReference: generateBookingReference(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const lang = (body.language ?? 'fr') as Lang
|
const lang = (body.language ?? 'fr') as Lang
|
||||||
@@ -214,7 +258,32 @@ export async function createMarketplaceReservation(body: {
|
|||||||
channels: ['IN_APP'],
|
channels: ['IN_APP'],
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
|
|
||||||
return { reservationId: reservation.id }
|
return {
|
||||||
|
reservationId: reservation.id,
|
||||||
|
bookingReference: (reservation as any).bookingReference ?? null,
|
||||||
|
companyName,
|
||||||
|
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
|
||||||
|
startDate: startDate.toISOString(),
|
||||||
|
endDate: endDate.toISOString(),
|
||||||
|
status: 'PENDING',
|
||||||
|
message: 'The company will review your request.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trackMarketplaceFunnelEvent(body: {
|
||||||
|
eventName: string
|
||||||
|
companySlug: string
|
||||||
|
vehicleId: string
|
||||||
|
sessionId?: string
|
||||||
|
path?: string
|
||||||
|
metadata?: Record<string, string | number | boolean | null>
|
||||||
|
}, renterId?: string) {
|
||||||
|
await repo.createMarketplaceFunnelEvent({
|
||||||
|
...body,
|
||||||
|
renterId: renterId ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCompanyPage(slug: string) {
|
export async function getCompanyPage(slug: string) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ vi.mock('./marketplace.repo', () => ({
|
|||||||
findListedCompanies: vi.fn(),
|
findListedCompanies: vi.fn(),
|
||||||
findPublishedVehicles: vi.fn(),
|
findPublishedVehicles: vi.fn(),
|
||||||
findVehicleForMarketplace: vi.fn(),
|
findVehicleForMarketplace: vi.fn(),
|
||||||
|
findVehicleForMarketplaceById: vi.fn(),
|
||||||
upsertMarketplaceCustomer: vi.fn(),
|
upsertMarketplaceCustomer: vi.fn(),
|
||||||
createMarketplaceReservation: vi.fn(),
|
createMarketplaceReservation: vi.fn(),
|
||||||
findCompanyPage: vi.fn(),
|
findCompanyPage: vi.fn(),
|
||||||
@@ -136,13 +137,14 @@ describe('createMarketplaceReservation', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('creates reservation and returns reservationId', async () => {
|
it('creates reservation and returns reservationId', async () => {
|
||||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||||
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||||
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1' } as any)
|
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
||||||
|
|
||||||
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||||
expect(result.reservationId).toBe('r-1')
|
expect(result.reservationId).toBe('r-1')
|
||||||
|
expect(result.bookingReference).toBe('BK-2026-ABC123')
|
||||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||||
identityDocumentNumber: 'CIN123456',
|
identityDocumentNumber: 'CIN123456',
|
||||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||||
@@ -155,20 +157,20 @@ describe('createMarketplaceReservation', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('throws NotFoundError when vehicle not found', async () => {
|
it('throws NotFoundError when vehicle not found', async () => {
|
||||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(null)
|
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null)
|
||||||
|
|
||||||
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws AppError when vehicle is unavailable', async () => {
|
it('throws AppError when vehicle is unavailable', async () => {
|
||||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||||
|
|
||||||
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
||||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { validateLicense } from '../../services/licenseValidationService'
|
|||||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||||
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
||||||
import { coerceNotificationLocale } from '../../services/notificationLocalizationService'
|
import { coerceNotificationLocale } from '../../services/notificationLocalizationService'
|
||||||
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
import { buildBookingRequestProgress, buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
||||||
import * as repo from './reservation.repo'
|
import * as repo from './reservation.repo'
|
||||||
|
|
||||||
function buildPickupAddress(value: unknown) {
|
function buildPickupAddress(value: unknown) {
|
||||||
@@ -47,7 +47,6 @@ async function assertLicenseCompliance(reservationId: string, companyId: string)
|
|||||||
export async function confirmReservation(id: string, companyId: string) {
|
export async function confirmReservation(id: string, companyId: string) {
|
||||||
const reservation = await repo.findByIdSimple(id, companyId)
|
const reservation = await repo.findByIdSimple(id, companyId)
|
||||||
if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status')
|
if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status')
|
||||||
await assertLicenseCompliance(reservation.id, companyId)
|
|
||||||
|
|
||||||
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
|
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
|
||||||
await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED')
|
await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED')
|
||||||
@@ -97,6 +96,44 @@ export async function confirmReservation(id: string, companyId: string) {
|
|||||||
},
|
},
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (reservation.source === 'MARKETPLACE') {
|
||||||
|
const progress = buildBookingRequestProgress({
|
||||||
|
source: reservation.source,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
paymentStatus: updated.paymentStatus,
|
||||||
|
customer,
|
||||||
|
})
|
||||||
|
const reminderChannels = reservation.renterId ? ['EMAIL', 'IN_APP'] as const : ['EMAIL'] as const
|
||||||
|
|
||||||
|
if (progress.documentsRequired) {
|
||||||
|
await sendNotification({
|
||||||
|
type: 'DOCUMENTS_REQUIRED',
|
||||||
|
companyId,
|
||||||
|
renterId: reservation.renterId ?? undefined,
|
||||||
|
email: customer.email,
|
||||||
|
channels: [...reminderChannels],
|
||||||
|
locale: reservation.renterId ? undefined : fallbackLocale,
|
||||||
|
title: 'Driver documents still needed',
|
||||||
|
body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} confirmed availability. Please send the remaining driver documents so the booking can move forward.`,
|
||||||
|
data: { reservationId: reservation.id, missingItems: progress.documentsMissing },
|
||||||
|
}).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.paymentRequired) {
|
||||||
|
await sendNotification({
|
||||||
|
type: 'PAYMENT_REQUIRED',
|
||||||
|
companyId,
|
||||||
|
renterId: reservation.renterId ?? undefined,
|
||||||
|
email: customer.email,
|
||||||
|
channels: [...reminderChannels],
|
||||||
|
locale: reservation.renterId ? undefined : fallbackLocale,
|
||||||
|
title: 'Payment or deposit is still pending',
|
||||||
|
body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} accepted your request. Complete any required payment or deposit to finalize the booking.`,
|
||||||
|
data: { reservationId: reservation.id, paymentStatus: updated.paymentStatus },
|
||||||
|
}).catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,10 +60,19 @@ describe('reservation.presenter boundary behavior', () => {
|
|||||||
const result = serializeReservationForDashboard({
|
const result = serializeReservationForDashboard({
|
||||||
id: 'reservation_1',
|
id: 'reservation_1',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
|
source: 'MARKETPLACE',
|
||||||
contractNumber: null,
|
contractNumber: null,
|
||||||
invoiceNumber: null,
|
invoiceNumber: null,
|
||||||
|
paymentStatus: 'UNPAID',
|
||||||
extras: { paymentMode: 'CARD' },
|
extras: { paymentMode: 'CARD' },
|
||||||
customer: { id: 'customer_1', licenseImageUrl: '/storage/raw-license.jpg' },
|
customer: {
|
||||||
|
id: 'customer_1',
|
||||||
|
driverLicense: null,
|
||||||
|
dateOfBirth: null,
|
||||||
|
address: {},
|
||||||
|
licenseImageUrl: '/storage/raw-license.jpg',
|
||||||
|
licenseValidationStatus: 'PENDING',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.paymentMode).toBe('CARD')
|
expect(result.paymentMode).toBe('CARD')
|
||||||
@@ -75,10 +84,19 @@ describe('reservation.presenter boundary behavior', () => {
|
|||||||
const result = serializeReservationForDashboard({
|
const result = serializeReservationForDashboard({
|
||||||
id: 'reservation_1',
|
id: 'reservation_1',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
|
source: 'MARKETPLACE',
|
||||||
contractNumber: null,
|
contractNumber: null,
|
||||||
invoiceNumber: null,
|
invoiceNumber: null,
|
||||||
|
paymentStatus: 'UNPAID',
|
||||||
extras: { paymentMode: 42 },
|
extras: { paymentMode: 42 },
|
||||||
customer: { id: 'customer_1', licenseImageUrl: null },
|
customer: {
|
||||||
|
id: 'customer_1',
|
||||||
|
driverLicense: null,
|
||||||
|
dateOfBirth: null,
|
||||||
|
address: {},
|
||||||
|
licenseImageUrl: null,
|
||||||
|
licenseValidationStatus: 'PENDING',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.paymentMode).toBeNull()
|
expect(result.paymentMode).toBeNull()
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ describe('serializeReservationForDashboard', () => {
|
|||||||
const result = serializeReservationForDashboard({
|
const result = serializeReservationForDashboard({
|
||||||
id: 'reservation-1',
|
id: 'reservation-1',
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
|
source: 'MARKETPLACE',
|
||||||
contractNumber: null,
|
contractNumber: null,
|
||||||
invoiceNumber: null,
|
invoiceNumber: null,
|
||||||
|
paymentStatus: 'UNPAID',
|
||||||
extras: {},
|
extras: {},
|
||||||
customer: {
|
customer: {
|
||||||
id: 'customer-1',
|
id: 'customer-1',
|
||||||
|
driverLicense: 'DL-1',
|
||||||
|
dateOfBirth: new Date('1990-01-01T00:00:00.000Z'),
|
||||||
|
address: { identityDocumentNumber: 'CIN-1', fullAddress: 'Casablanca' },
|
||||||
licenseImageUrl: 'http://localhost:4000/storage/companies/company-1/customers/customer-1/license.jpg',
|
licenseImageUrl: 'http://localhost:4000/storage/companies/company-1/customers/customer-1/license.jpg',
|
||||||
|
licenseValidationStatus: 'APPROVED',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export function normalizeOptionalString(value: string | null | undefined): strin
|
|||||||
return trimmed || null
|
return trimmed || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readAddressField(address: unknown, key: string): string | null {
|
||||||
|
const extras = parseReservationExtras(address)
|
||||||
|
const value = extras[key]
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
export function buildReservationWorkflow(reservation: {
|
export function buildReservationWorkflow(reservation: {
|
||||||
status: string
|
status: string
|
||||||
contractNumber: string | null
|
contractNumber: string | null
|
||||||
@@ -33,16 +39,95 @@ export function buildReservationWorkflow(reservation: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildBookingRequestProgress(reservation: {
|
||||||
|
source: string
|
||||||
|
status: string
|
||||||
|
paymentStatus?: string | null
|
||||||
|
customer?: {
|
||||||
|
driverLicense?: string | null
|
||||||
|
dateOfBirth?: Date | null
|
||||||
|
address?: unknown
|
||||||
|
licenseImageUrl?: string | null
|
||||||
|
licenseValidationStatus?: string | null
|
||||||
|
} | null
|
||||||
|
}) {
|
||||||
|
const customer = reservation.customer
|
||||||
|
const paymentStatus = reservation.paymentStatus ?? 'UNPAID'
|
||||||
|
const identityDocumentNumber = readAddressField(customer?.address, 'identityDocumentNumber')
|
||||||
|
const fullAddress = readAddressField(customer?.address, 'fullAddress')
|
||||||
|
|
||||||
|
const documentsMissing: string[] = []
|
||||||
|
|
||||||
|
if (!customer?.dateOfBirth) documentsMissing.push('DATE_OF_BIRTH')
|
||||||
|
if (!identityDocumentNumber) documentsMissing.push('IDENTITY_DOCUMENT')
|
||||||
|
if (!fullAddress) documentsMissing.push('FULL_ADDRESS')
|
||||||
|
if (!customer?.driverLicense) documentsMissing.push('DRIVER_LICENSE')
|
||||||
|
if (!customer?.licenseImageUrl) documentsMissing.push('LICENSE_IMAGE')
|
||||||
|
if ((customer?.licenseValidationStatus ?? 'PENDING') !== 'APPROVED') {
|
||||||
|
documentsMissing.push('LICENSE_APPROVAL')
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
|
||||||
|
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0
|
||||||
|
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
|
||||||
|
|
||||||
|
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
|
||||||
|
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
|
||||||
|
else if (!companyConfirmed) stage = 'REQUEST_SENT'
|
||||||
|
else if (documentsRequired) stage = 'DOCUMENTS_REQUIRED'
|
||||||
|
else if (paymentRequired) stage = 'PAYMENT_REQUIRED'
|
||||||
|
else if (companyConfirmed) stage = 'BOOKING_CONFIRMED'
|
||||||
|
else stage = 'COMPANY_CONFIRMED'
|
||||||
|
|
||||||
|
const activeStepKey =
|
||||||
|
stage === 'REQUEST_SENT'
|
||||||
|
? 'REQUEST_SENT'
|
||||||
|
: stage === 'CANCELLED'
|
||||||
|
? 'BOOKING_CONFIRMED'
|
||||||
|
: stage === 'DOCUMENTS_REQUIRED'
|
||||||
|
? 'DOCUMENTS_COMPLETED'
|
||||||
|
: stage === 'PAYMENT_REQUIRED'
|
||||||
|
? 'PAYMENT_COMPLETED'
|
||||||
|
: 'BOOKING_CONFIRMED'
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ key: 'REQUEST_SENT', completed: true, active: activeStepKey === 'REQUEST_SENT' },
|
||||||
|
{ key: 'AVAILABILITY_CONFIRMED', completed: companyConfirmed, active: companyConfirmed && activeStepKey === 'REQUEST_SENT' },
|
||||||
|
{ key: 'DOCUMENTS_COMPLETED', completed: companyConfirmed && !documentsRequired, active: activeStepKey === 'DOCUMENTS_COMPLETED' },
|
||||||
|
{ key: 'PAYMENT_COMPLETED', completed: companyConfirmed && !paymentRequired, active: activeStepKey === 'PAYMENT_COMPLETED' },
|
||||||
|
{ key: 'BOOKING_CONFIRMED', completed: companyConfirmed && !documentsRequired && !paymentRequired, active: activeStepKey === 'BOOKING_CONFIRMED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage,
|
||||||
|
companyConfirmed,
|
||||||
|
documentsRequired,
|
||||||
|
paymentRequired,
|
||||||
|
documentsMissing,
|
||||||
|
steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function serializeReservationForDashboard<T extends {
|
export function serializeReservationForDashboard<T extends {
|
||||||
extras: unknown
|
extras: unknown
|
||||||
status: string
|
status: string
|
||||||
|
source: string
|
||||||
contractNumber: string | null
|
contractNumber: string | null
|
||||||
invoiceNumber: string | null
|
invoiceNumber: string | null
|
||||||
|
paymentStatus?: string | null
|
||||||
customer?: {
|
customer?: {
|
||||||
id: string
|
id: string
|
||||||
|
driverLicense?: string | null
|
||||||
|
dateOfBirth?: Date | null
|
||||||
|
address?: unknown
|
||||||
licenseImageUrl?: string | null
|
licenseImageUrl?: string | null
|
||||||
|
licenseValidationStatus?: string | null
|
||||||
} | null
|
} | null
|
||||||
}>(reservation: T): T & { paymentMode: string | null; workflow: ReturnType<typeof buildReservationWorkflow> } {
|
}>(reservation: T): T & {
|
||||||
|
paymentMode: string | null
|
||||||
|
workflow: ReturnType<typeof buildReservationWorkflow>
|
||||||
|
bookingRequest: ReturnType<typeof buildBookingRequestProgress>
|
||||||
|
} {
|
||||||
const extras = parseReservationExtras(reservation.extras)
|
const extras = parseReservationExtras(reservation.extras)
|
||||||
const customer =
|
const customer =
|
||||||
reservation.customer
|
reservation.customer
|
||||||
@@ -59,5 +144,6 @@ export function serializeReservationForDashboard<T extends {
|
|||||||
...(customer !== undefined ? { customer } : {}),
|
...(customer !== undefined ? { customer } : {}),
|
||||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||||
workflow: buildReservationWorkflow(reservation),
|
workflow: buildReservationWorkflow(reservation),
|
||||||
|
bookingRequest: buildBookingRequestProgress({ ...reservation, customer }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,13 @@ function maskPhone(phone?: string | null) {
|
|||||||
export function presentPublicBooking(reservation: any) {
|
export function presentPublicBooking(reservation: any) {
|
||||||
return {
|
return {
|
||||||
id: reservation.id,
|
id: reservation.id,
|
||||||
status: reservation.status,
|
bookingReference: reservation.bookingReference ?? null,
|
||||||
|
status: reservation.status === 'DRAFT' ? 'PENDING' : reservation.status,
|
||||||
|
companyName: reservation.company?.brand?.displayName ?? reservation.company?.name ?? null,
|
||||||
pickupAt: reservation.startDate instanceof Date ? reservation.startDate.toISOString() : reservation.startDate,
|
pickupAt: reservation.startDate instanceof Date ? reservation.startDate.toISOString() : reservation.startDate,
|
||||||
returnAt: reservation.endDate instanceof Date ? reservation.endDate.toISOString() : reservation.endDate,
|
returnAt: reservation.endDate instanceof Date ? reservation.endDate.toISOString() : reservation.endDate,
|
||||||
|
pickupLocation: reservation.pickupLocation ?? null,
|
||||||
|
returnLocation: reservation.returnLocation ?? null,
|
||||||
vehicle: {
|
vehicle: {
|
||||||
make: reservation.vehicle?.make,
|
make: reservation.vehicle?.make,
|
||||||
model: reservation.vehicle?.model,
|
model: reservation.vehicle?.model,
|
||||||
@@ -72,5 +76,6 @@ export function presentPublicBooking(reservation: any) {
|
|||||||
currency: 'MAD',
|
currency: 'MAD',
|
||||||
},
|
},
|
||||||
paymentStatus: reservation.paymentStatus,
|
paymentStatus: reservation.paymentStatus,
|
||||||
|
message: 'The company will review your request.',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,46 +45,53 @@ export async function upsertCustomer(companyId: string, data: {
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
phone: string
|
phone: string
|
||||||
driverLicense: string
|
driverLicense?: string
|
||||||
dateOfBirth: string
|
dateOfBirth?: string
|
||||||
licenseExpiry: string
|
licenseExpiry?: string
|
||||||
licenseIssuedAt: string
|
licenseIssuedAt?: string
|
||||||
nationality: string
|
nationality?: string
|
||||||
identityDocumentNumber: string
|
identityDocumentNumber?: string
|
||||||
fullAddress: string
|
fullAddress?: string
|
||||||
licenseCountry: string
|
licenseCountry?: string
|
||||||
licenseNumber?: string | null
|
licenseNumber?: string | null
|
||||||
licenseCategory: string
|
licenseCategory?: string
|
||||||
internationalLicenseNumber?: string | null
|
internationalLicenseNumber?: string | null
|
||||||
}) {
|
}) {
|
||||||
|
const normalizedEmail = data.email.trim().toLowerCase()
|
||||||
const existing = await prisma.customer.findUnique({
|
const existing = await prisma.customer.findUnique({
|
||||||
where: { companyId_email: { companyId, email: data.email } },
|
where: { companyId_email: { companyId, email: normalizedEmail } },
|
||||||
select: { id: true, address: true },
|
select: { id: true, address: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const address = {
|
const addressPatch = {
|
||||||
|
...(data.fullAddress ? { fullAddress: data.fullAddress } : {}),
|
||||||
|
...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}),
|
||||||
|
...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = Object.keys(addressPatch).length > 0
|
||||||
|
? {
|
||||||
...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)
|
...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)
|
||||||
? existing.address as Record<string, unknown>
|
? existing.address as Record<string, unknown>
|
||||||
: {}),
|
: {}),
|
||||||
fullAddress: data.fullAddress,
|
...addressPatch,
|
||||||
identityDocumentNumber: data.identityDocumentNumber,
|
|
||||||
internationalLicenseNumber: data.internationalLicenseNumber ?? null,
|
|
||||||
}
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
lastName: data.lastName,
|
lastName: data.lastName,
|
||||||
email: data.email,
|
email: normalizedEmail,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
driverLicense: data.driverLicense,
|
...(data.driverLicense ? { driverLicense: data.driverLicense } : {}),
|
||||||
dateOfBirth: new Date(data.dateOfBirth),
|
...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}),
|
||||||
nationality: data.nationality,
|
...(data.nationality ? { nationality: data.nationality } : {}),
|
||||||
address,
|
...(address ? { address } : {}),
|
||||||
licenseExpiry: new Date(data.licenseExpiry),
|
...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}),
|
||||||
licenseIssuedAt: new Date(data.licenseIssuedAt),
|
...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}),
|
||||||
licenseCountry: data.licenseCountry,
|
...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}),
|
||||||
licenseNumber: data.licenseNumber ?? data.driverLicense,
|
...((data.licenseNumber ?? data.driverLicense) ? { licenseNumber: data.licenseNumber ?? data.driverLicense } : {}),
|
||||||
licenseCategory: data.licenseCategory,
|
...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -106,14 +113,14 @@ export async function createReservation(data: object) {
|
|||||||
export async function findReservationWithDetails(reservationId: string) {
|
export async function findReservationWithDetails(reservationId: string) {
|
||||||
return prisma.reservation.findUniqueOrThrow({
|
return prisma.reservation.findUniqueOrThrow({
|
||||||
where: { id: reservationId },
|
where: { id: reservationId },
|
||||||
include: { insurances: true, additionalDrivers: true },
|
include: { insurances: true, additionalDrivers: true, vehicle: true, customer: true, company: { include: { brand: true } } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findBooking(reservationId: string, companyId: string) {
|
export async function findBooking(reservationId: string, companyId: string) {
|
||||||
return prisma.reservation.findFirstOrThrow({
|
return prisma.reservation.findFirstOrThrow({
|
||||||
where: { id: reservationId, companyId },
|
where: { id: reservationId, companyId },
|
||||||
include: { vehicle: true, customer: true },
|
include: { vehicle: true, customer: true, company: { include: { brand: true } } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,8 @@ describe('site.schemas', () => {
|
|||||||
lastName: 'Benali',
|
lastName: 'Benali',
|
||||||
email: 'aya@example.com',
|
email: 'aya@example.com',
|
||||||
phone: '+212600000000',
|
phone: '+212600000000',
|
||||||
driverLicense: 'DL-123',
|
pickupLocation: 'Casablanca Airport',
|
||||||
dateOfBirth: '1995-01-01T00:00:00.000Z',
|
returnLocation: 'Casablanca Airport',
|
||||||
licenseExpiry: '2028-01-01T00:00:00.000Z',
|
|
||||||
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
|
|
||||||
nationality: 'Moroccan',
|
|
||||||
identityDocumentNumber: 'ID-123',
|
|
||||||
fullAddress: '12 Main Street',
|
|
||||||
licenseCountry: 'MA',
|
|
||||||
licenseCategory: 'B',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it('defaults optional public booking arrays and source', () => {
|
it('defaults optional public booking arrays and source', () => {
|
||||||
@@ -53,7 +46,7 @@ describe('site.schemas', () => {
|
|||||||
expect(paySchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.test', failureUrl: 'https://fail.test', accessToken: 'booking-access-token-123' }).currency).toBe('MAD')
|
expect(paySchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.test', failureUrl: 'https://fail.test', accessToken: 'booking-access-token-123' }).currency).toBe('MAD')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps contact, PayPal capture, and required identity fields strict', () => {
|
it('keeps contact, PayPal capture, and guest booking essentials strict', () => {
|
||||||
expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({
|
expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({
|
||||||
name: 'Aya', email: 'aya@example.com', message: 'Hello',
|
name: 'Aya', email: 'aya@example.com', message: 'Hello',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,16 +19,18 @@ export const bookSchema = z.object({
|
|||||||
lastName: z.string().min(1),
|
lastName: z.string().min(1),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
phone: z.string().min(1).max(30),
|
phone: z.string().min(1).max(30),
|
||||||
driverLicense: z.string().min(1).max(50),
|
pickupLocation: z.string().trim().min(1).max(100),
|
||||||
dateOfBirth: z.string().datetime(),
|
returnLocation: z.string().trim().min(1).max(100),
|
||||||
licenseExpiry: z.string().datetime(),
|
driverLicense: z.string().min(1).max(50).optional(),
|
||||||
licenseIssuedAt: z.string().datetime(),
|
dateOfBirth: z.string().datetime().optional(),
|
||||||
nationality: z.string().min(1).max(100),
|
licenseExpiry: z.string().datetime().optional(),
|
||||||
identityDocumentNumber: z.string().min(1).max(100),
|
licenseIssuedAt: z.string().datetime().optional(),
|
||||||
fullAddress: z.string().min(1).max(500),
|
nationality: z.string().min(1).max(100).optional(),
|
||||||
licenseCountry: z.string().min(1).max(100),
|
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||||
|
fullAddress: z.string().min(1).max(500).optional(),
|
||||||
|
licenseCountry: z.string().min(1).max(100).optional(),
|
||||||
licenseNumber: z.string().min(1).max(50).optional(),
|
licenseNumber: z.string().min(1).max(50).optional(),
|
||||||
licenseCategory: z.string().min(1).max(20),
|
licenseCategory: z.string().min(1).max(20).optional(),
|
||||||
internationalLicenseNumber: z.string().trim().max(100).optional(),
|
internationalLicenseNumber: z.string().trim().max(100).optional(),
|
||||||
offerId: z.string().cuid().optional(),
|
offerId: z.string().cuid().optional(),
|
||||||
promoCodeUsed: z.string().optional(),
|
promoCodeUsed: z.string().optional(),
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ vi.mock('../../services/pricingRuleService', () => ({ applyPricingRules: vi.fn()
|
|||||||
vi.mock('../../services/licenseValidationService', () => ({ validateLicense: vi.fn(), validateAndFlagLicense: vi.fn().mockResolvedValue(undefined) }))
|
vi.mock('../../services/licenseValidationService', () => ({ validateLicense: vi.fn(), validateAndFlagLicense: vi.fn().mockResolvedValue(undefined) }))
|
||||||
vi.mock('../../services/amanpayService', () => ({ isConfigured: vi.fn(), createCheckout: vi.fn(), verifyWebhookSignature: vi.fn(), findTransactionFromWebhook: vi.fn() }))
|
vi.mock('../../services/amanpayService', () => ({ isConfigured: vi.fn(), createCheckout: vi.fn(), verifyWebhookSignature: vi.fn(), findTransactionFromWebhook: vi.fn() }))
|
||||||
vi.mock('../../services/paypalService', () => ({ createOrder: vi.fn(), captureOrder: vi.fn() }))
|
vi.mock('../../services/paypalService', () => ({ createOrder: vi.fn(), captureOrder: vi.fn() }))
|
||||||
vi.mock('./site.presenter', () => ({ presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })) }))
|
vi.mock('./site.presenter', () => ({
|
||||||
|
presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })),
|
||||||
|
presentPublicBooking: vi.fn((reservation: any) => ({
|
||||||
|
bookingReference: reservation.bookingReference ?? null,
|
||||||
|
status: reservation.status === 'DRAFT' ? 'PENDING' : reservation.status,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
vi.mock('./site.repo', () => ({
|
vi.mock('./site.repo', () => ({
|
||||||
findCompanyBySlug: vi.fn(),
|
findCompanyBySlug: vi.fn(),
|
||||||
findPublishedVehicles: vi.fn(),
|
findPublishedVehicles: vi.fn(),
|
||||||
@@ -62,15 +68,8 @@ function bookingBody(overrides: Record<string, unknown> = {}) {
|
|||||||
lastName: 'Renter',
|
lastName: 'Renter',
|
||||||
email: 'nora@example.test',
|
email: 'nora@example.test',
|
||||||
phone: '+212600000000',
|
phone: '+212600000000',
|
||||||
driverLicense: 'DL-1',
|
pickupLocation: 'Casablanca',
|
||||||
dateOfBirth: '1992-01-01',
|
returnLocation: 'Casablanca',
|
||||||
licenseExpiry: '2027-01-01',
|
|
||||||
licenseIssuedAt: '2022-01-01',
|
|
||||||
nationality: 'MA',
|
|
||||||
identityDocumentNumber: 'ID-1',
|
|
||||||
fullAddress: 'Casablanca',
|
|
||||||
licenseCountry: 'MA',
|
|
||||||
licenseCategory: 'B',
|
|
||||||
...overrides,
|
...overrides,
|
||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
@@ -108,7 +107,23 @@ describe('site.service public booking/payment boundaries', () => {
|
|||||||
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
|
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
|
||||||
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
|
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
|
||||||
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||||
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'reservation_1', additionalDrivers: [{ requiresApproval: true }] } as never)
|
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({
|
||||||
|
id: 'reservation_1',
|
||||||
|
bookingReference: 'BK-2026-AB12CD',
|
||||||
|
status: 'DRAFT',
|
||||||
|
startDate: new Date('2026-07-01T10:00:00.000Z'),
|
||||||
|
endDate: new Date('2026-07-04T10:00:00.000Z'),
|
||||||
|
pickupLocation: 'Casablanca',
|
||||||
|
returnLocation: 'Casablanca',
|
||||||
|
vehicle: { make: 'Dacia', model: 'Duster', year: 2024 },
|
||||||
|
customer: { firstName: 'Aya', lastName: 'Benali', email: 'aya@example.test', phone: '+212600000000' },
|
||||||
|
company: { name: 'Atlas Cars', brand: { displayName: 'Atlas Cars' } },
|
||||||
|
additionalDrivers: [],
|
||||||
|
insurances: [],
|
||||||
|
totalAmount: 1090,
|
||||||
|
paidAmount: 0,
|
||||||
|
paymentStatus: 'UNPAID',
|
||||||
|
} as never)
|
||||||
|
|
||||||
const result = await service.createBooking('atlas', bookingBody({
|
const result = await service.createBooking('atlas', bookingBody({
|
||||||
promoCodeUsed: 'FREEDAY',
|
promoCodeUsed: 'FREEDAY',
|
||||||
@@ -122,13 +137,16 @@ describe('site.service public booking/payment boundaries', () => {
|
|||||||
discountAmount: 500,
|
discountAmount: 500,
|
||||||
pricingRulesTotal: 90,
|
pricingRulesTotal: 90,
|
||||||
totalAmount: 1090,
|
totalAmount: 1090,
|
||||||
|
pickupLocation: 'Casablanca',
|
||||||
|
returnLocation: 'Casablanca',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
source: 'PUBLIC_SITE',
|
source: 'PUBLIC_SITE',
|
||||||
}))
|
}))
|
||||||
expect(applyInsurancesToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', ['insurance_1'], 3, 1500)
|
expect(applyInsurancesToReservation).not.toHaveBeenCalled()
|
||||||
expect(applyAdditionalDriversToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', [{ firstName: 'Second' }], 3)
|
expect(applyAdditionalDriversToReservation).not.toHaveBeenCalled()
|
||||||
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1')
|
expect(validateAndFlagLicense).not.toHaveBeenCalled()
|
||||||
expect(result.requiresManualApproval).toBe(true)
|
expect(result.status).toBe('PENDING')
|
||||||
|
expect(result.bookingReference).toBe('BK-2026-AB12CD')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects payment initialization for already-paid reservations before provider calls', async () => {
|
it('rejects payment initialization for already-paid reservations before provider calls', async () => {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
||||||
import { AppError, NotFoundError } from '../../http/errors'
|
import { AppError, NotFoundError } from '../../http/errors'
|
||||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||||
import { applyInsurancesToReservation } from '../../services/insuranceService'
|
|
||||||
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService'
|
|
||||||
import { applyPricingRules } from '../../services/pricingRuleService'
|
import { applyPricingRules } from '../../services/pricingRuleService'
|
||||||
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
|
import { validateLicense } from '../../services/licenseValidationService'
|
||||||
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
|
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import * as amanpay from '../../services/amanpayService'
|
import * as amanpay from '../../services/amanpayService'
|
||||||
@@ -48,6 +46,12 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateBookingReference() {
|
||||||
|
const year = new Date().getUTCFullYear()
|
||||||
|
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||||
|
return `BK-${year}-${random}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPlatformHomepage() {
|
export async function getPlatformHomepage() {
|
||||||
return getMarketplaceHomepageContent()
|
return getMarketplaceHomepageContent()
|
||||||
}
|
}
|
||||||
@@ -132,8 +136,9 @@ export async function validatePromoCode(slug: string, code: string) {
|
|||||||
export async function createBooking(slug: string, body: {
|
export async function createBooking(slug: string, body: {
|
||||||
vehicleId: string; startDate: string; endDate: string
|
vehicleId: string; startDate: string; endDate: string
|
||||||
firstName: string; lastName: string; email: string; phone: string
|
firstName: string; lastName: string; email: string; phone: string
|
||||||
driverLicense: string; dateOfBirth: string; licenseExpiry: string; licenseIssuedAt: string; nationality: string
|
pickupLocation: string; returnLocation: string
|
||||||
identityDocumentNumber: string; fullAddress: string; licenseCountry: string; licenseNumber?: string; licenseCategory: string; internationalLicenseNumber?: string
|
driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: string
|
||||||
|
identityDocumentNumber?: string; fullAddress?: string; licenseCountry?: string; licenseNumber?: string; licenseCategory?: string; internationalLicenseNumber?: string
|
||||||
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
|
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
|
||||||
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
||||||
}) {
|
}) {
|
||||||
@@ -143,6 +148,7 @@ export async function createBooking(slug: string, body: {
|
|||||||
const start = new Date(body.startDate)
|
const start = new Date(body.startDate)
|
||||||
const end = new Date(body.endDate)
|
const end = new Date(body.endDate)
|
||||||
if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||||
|
if (start < new Date()) throw new AppError('Start date cannot be in the past', 400, 'invalid_dates')
|
||||||
|
|
||||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.id, range: { startDate: start, endDate: end } })
|
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.id, range: { startDate: start, endDate: end } })
|
||||||
if (!availability.available) {
|
if (!availability.available) {
|
||||||
@@ -182,16 +188,10 @@ export async function createBooking(slug: string, body: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const additionalDriversList = body.additionalDrivers ?? []
|
|
||||||
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
||||||
company.id, customer.id, additionalDriversList as any[], vehicle.dailyRate, totalDays,
|
company.id, customer.id, [], vehicle.dailyRate, totalDays,
|
||||||
)
|
)
|
||||||
|
|
||||||
const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null)
|
|
||||||
if (primaryLicenseResult.status === 'EXPIRED') {
|
|
||||||
throw new AppError('The primary driver license is expired', 400, 'license_expired')
|
|
||||||
}
|
|
||||||
|
|
||||||
const reservation = await repo.createReservation({
|
const reservation = await repo.createReservation({
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
vehicleId: vehicle.id,
|
vehicleId: vehicle.id,
|
||||||
@@ -202,6 +202,8 @@ export async function createBooking(slug: string, body: {
|
|||||||
source: body.source ?? 'PUBLIC_SITE',
|
source: body.source ?? 'PUBLIC_SITE',
|
||||||
startDate: start,
|
startDate: start,
|
||||||
endDate: end,
|
endDate: end,
|
||||||
|
pickupLocation: body.pickupLocation,
|
||||||
|
returnLocation: body.returnLocation,
|
||||||
dailyRate: vehicle.dailyRate,
|
dailyRate: vehicle.dailyRate,
|
||||||
totalDays,
|
totalDays,
|
||||||
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
|
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
|
||||||
@@ -209,20 +211,10 @@ export async function createBooking(slug: string, body: {
|
|||||||
pricingRulesApplied: applied,
|
pricingRulesApplied: applied,
|
||||||
pricingRulesTotal,
|
pricingRulesTotal,
|
||||||
notes: body.notes ?? null,
|
notes: body.notes ?? null,
|
||||||
|
bookingReference: generateBookingReference(),
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
})
|
})
|
||||||
|
|
||||||
const insuranceIds = body.selectedInsurancePolicyIds ?? []
|
|
||||||
if (insuranceIds.length > 0) {
|
|
||||||
await applyInsurancesToReservation(reservation.id, company.id, insuranceIds, totalDays, baseAmount)
|
|
||||||
}
|
|
||||||
if (additionalDriversList.length > 0) {
|
|
||||||
await applyAdditionalDriversToReservation(reservation.id, company.id, additionalDriversList, totalDays)
|
|
||||||
}
|
|
||||||
if (body.licenseExpiry) {
|
|
||||||
await validateAndFlagLicense(customer.id).catch(() => null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicAccess = generatePublicAccessToken()
|
const publicAccess = generatePublicAccessToken()
|
||||||
await repo.createReservationPublicAccess(
|
await repo.createReservationPublicAccess(
|
||||||
reservation.id,
|
reservation.id,
|
||||||
@@ -232,11 +224,10 @@ export async function createBooking(slug: string, body: {
|
|||||||
|
|
||||||
const refreshed = await repo.findReservationWithDetails(reservation.id)
|
const refreshed = await repo.findReservationWithDetails(reservation.id)
|
||||||
return {
|
return {
|
||||||
...refreshed,
|
...presentPublicBooking({ ...refreshed, company }),
|
||||||
publicAccessToken: publicAccess.token,
|
publicAccessToken: publicAccess.token,
|
||||||
requiresManualApproval:
|
bookingReference: (refreshed as any).bookingReference,
|
||||||
primaryLicenseResult.requiresApproval ||
|
message: 'The company will review your request.',
|
||||||
refreshed.additionalDrivers.some((d: any) => d.requiresApproval),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,9 +175,12 @@ describe('validatePromoCode', () => {
|
|||||||
describe('createBooking', () => {
|
describe('createBooking', () => {
|
||||||
const baseBody = {
|
const baseBody = {
|
||||||
vehicleId: 'v-1',
|
vehicleId: 'v-1',
|
||||||
startDate: '2025-06-01T00:00:00.000Z',
|
startDate: '2026-07-01T00:00:00.000Z',
|
||||||
endDate: '2025-06-04T00:00:00.000Z',
|
endDate: '2026-07-04T00:00:00.000Z',
|
||||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||||
|
phone: '+212600000000',
|
||||||
|
pickupLocation: 'Casablanca',
|
||||||
|
returnLocation: 'Casablanca',
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -186,20 +189,36 @@ describe('createBooking', () => {
|
|||||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||||
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||||
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
|
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
|
||||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
|
||||||
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'r-1' } as any)
|
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'r-1' } as any)
|
||||||
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'r-1', additionalDrivers: [], insurances: [] } as any)
|
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({
|
||||||
|
id: 'r-1',
|
||||||
|
bookingReference: 'BK-2026-ABC123',
|
||||||
|
status: 'DRAFT',
|
||||||
|
startDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||||
|
endDate: new Date('2026-07-04T00:00:00.000Z'),
|
||||||
|
pickupLocation: 'Casablanca',
|
||||||
|
returnLocation: 'Casablanca',
|
||||||
|
vehicle: { make: 'Toyota', model: 'Camry', year: 2022 },
|
||||||
|
customer: { firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', phone: '+212600000000' },
|
||||||
|
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
|
||||||
|
additionalDrivers: [],
|
||||||
|
insurances: [],
|
||||||
|
totalAmount: 30000,
|
||||||
|
paidAmount: 0,
|
||||||
|
paymentStatus: 'UNPAID',
|
||||||
|
} as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates a booking and returns reservation with requiresManualApproval flag', async () => {
|
it('creates a booking and returns a pending guest confirmation', async () => {
|
||||||
const result = await createBooking(SLUG, baseBody)
|
const result = await createBooking(SLUG, baseBody)
|
||||||
expect(repo.createReservation).toHaveBeenCalledOnce()
|
expect(repo.createReservation).toHaveBeenCalledOnce()
|
||||||
expect((result as any).requiresManualApproval).toBe(false)
|
expect((result as any).status).toBe('PENDING')
|
||||||
|
expect((result as any).bookingReference).toBe('BK-2026-ABC123')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws AppError for invalid dates', async () => {
|
it('throws AppError for invalid dates', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
createBooking(SLUG, { ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
createBooking(SLUG, { ...baseBody, startDate: '2026-07-04T00:00:00.000Z', endDate: '2026-07-01T00:00:00.000Z' }),
|
||||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -207,11 +226,6 @@ describe('createBooking', () => {
|
|||||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||||
await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws AppError when primary driver license is expired', async () => {
|
|
||||||
vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRED', requiresApproval: false } as any)
|
|
||||||
await expect(createBooking(SLUG, { ...baseBody, licenseExpiry: '2020-01-01T00:00:00.000Z' })).rejects.toMatchObject({ error: 'license_expired' })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ interface OnlineReservation {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
vehicle: { make: string; model: string; year: number; licensePlate: string }
|
vehicle: { make: string; model: string; year: number; licensePlate: string }
|
||||||
customer: { firstName: string; lastName: string; email: string; phone: string | null }
|
customer: { firstName: string; lastName: string; email: string; phone: string | null }
|
||||||
|
bookingRequest: {
|
||||||
|
stage: string
|
||||||
|
documentsRequired: boolean
|
||||||
|
paymentRequired: boolean
|
||||||
|
documentsMissing: string[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, string> = {
|
const STATUS_STYLE: Record<string, string> = {
|
||||||
@@ -42,6 +48,33 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
NO_SHOW: 'No show',
|
NO_SHOW: 'No show',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REQUEST_STAGE_STYLE: Record<string, string> = {
|
||||||
|
REQUEST_SENT: 'bg-orange-100 text-orange-800',
|
||||||
|
COMPANY_CONFIRMED: 'bg-blue-100 text-blue-800',
|
||||||
|
DOCUMENTS_REQUIRED: 'bg-amber-100 text-amber-800',
|
||||||
|
PAYMENT_REQUIRED: 'bg-purple-100 text-purple-800',
|
||||||
|
BOOKING_CONFIRMED: 'bg-emerald-100 text-emerald-700',
|
||||||
|
CANCELLED: 'bg-rose-100 text-rose-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUEST_STAGE_LABEL: Record<string, string> = {
|
||||||
|
REQUEST_SENT: 'Waiting for company review',
|
||||||
|
COMPANY_CONFIRMED: 'Availability confirmed',
|
||||||
|
DOCUMENTS_REQUIRED: 'Driver documents missing',
|
||||||
|
PAYMENT_REQUIRED: 'Payment or deposit pending',
|
||||||
|
BOOKING_CONFIRMED: 'Ready to proceed',
|
||||||
|
CANCELLED: 'Request closed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MISSING_ITEM_LABEL: Record<string, string> = {
|
||||||
|
DATE_OF_BIRTH: 'Date of birth',
|
||||||
|
IDENTITY_DOCUMENT: 'Identity document',
|
||||||
|
FULL_ADDRESS: 'Full address',
|
||||||
|
DRIVER_LICENSE: 'Driver license number',
|
||||||
|
LICENSE_IMAGE: 'License image',
|
||||||
|
LICENSE_APPROVAL: 'License approval',
|
||||||
|
}
|
||||||
|
|
||||||
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
|
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
return (
|
return (
|
||||||
@@ -237,6 +270,8 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {
|
|||||||
onDecline: () => void
|
onDecline: () => void
|
||||||
}) {
|
}) {
|
||||||
const days = r.totalDays
|
const days = r.totalDays
|
||||||
|
const missingItems = r.bookingRequest.documentsMissing.map((item) => MISSING_ITEM_LABEL[item] ?? item)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card overflow-hidden border-l-4 border-l-orange-400">
|
<div className="card overflow-hidden border-l-4 border-l-orange-400">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
@@ -267,6 +302,27 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {
|
|||||||
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
|
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${REQUEST_STAGE_STYLE[r.bookingRequest.stage] ?? 'bg-slate-100 text-slate-700'}`}>
|
||||||
|
{REQUEST_STAGE_LABEL[r.bookingRequest.stage] ?? r.bookingRequest.stage}
|
||||||
|
</span>
|
||||||
|
{r.bookingRequest.paymentRequired ? (
|
||||||
|
<span className="inline-flex rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
|
||||||
|
Payment pending
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{missingItems.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{missingItems.map((item) => (
|
||||||
|
<span key={item} className="inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: total + actions */}
|
{/* Right: total + actions */}
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ interface ReservationDetail {
|
|||||||
checkInInspectionEditable: boolean
|
checkInInspectionEditable: boolean
|
||||||
checkOutInspectionEditable: boolean
|
checkOutInspectionEditable: boolean
|
||||||
}
|
}
|
||||||
|
bookingRequest: {
|
||||||
|
stage: string
|
||||||
|
companyConfirmed: boolean
|
||||||
|
documentsRequired: boolean
|
||||||
|
paymentRequired: boolean
|
||||||
|
documentsMissing: string[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditMode = 'booking' | 'return' | null
|
type EditMode = 'booking' | 'return' | null
|
||||||
@@ -118,6 +125,24 @@ const detailCopy = {
|
|||||||
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
|
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
|
||||||
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
|
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
|
||||||
licenseImageLabel: 'License image',
|
licenseImageLabel: 'License image',
|
||||||
|
bookingProgress: 'Booking progress',
|
||||||
|
paymentPending: 'Payment or deposit still pending',
|
||||||
|
stageLabels: {
|
||||||
|
REQUEST_SENT: 'Waiting for company review',
|
||||||
|
COMPANY_CONFIRMED: 'Availability confirmed',
|
||||||
|
DOCUMENTS_REQUIRED: 'Driver documents missing',
|
||||||
|
PAYMENT_REQUIRED: 'Payment or deposit pending',
|
||||||
|
BOOKING_CONFIRMED: 'Ready to proceed',
|
||||||
|
CANCELLED: 'Request closed',
|
||||||
|
},
|
||||||
|
missingItems: {
|
||||||
|
DATE_OF_BIRTH: 'Date of birth',
|
||||||
|
IDENTITY_DOCUMENT: 'Identity document',
|
||||||
|
FULL_ADDRESS: 'Full address',
|
||||||
|
DRIVER_LICENSE: 'Driver license number',
|
||||||
|
LICENSE_IMAGE: 'License image',
|
||||||
|
LICENSE_APPROVAL: 'License approval',
|
||||||
|
},
|
||||||
noLicenseImage: 'No license image uploaded.',
|
noLicenseImage: 'No license image uploaded.',
|
||||||
pickupPhotosTitle: 'Pickup photos',
|
pickupPhotosTitle: 'Pickup photos',
|
||||||
dropoffPhotosTitle: 'Return photos',
|
dropoffPhotosTitle: 'Return photos',
|
||||||
@@ -167,6 +192,24 @@ const detailCopy = {
|
|||||||
checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.',
|
checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.',
|
||||||
inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.',
|
inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.',
|
||||||
licenseImageLabel: 'Image du permis',
|
licenseImageLabel: 'Image du permis',
|
||||||
|
bookingProgress: 'Progression de la réservation',
|
||||||
|
paymentPending: 'Paiement ou dépôt encore en attente',
|
||||||
|
stageLabels: {
|
||||||
|
REQUEST_SENT: 'En attente de validation',
|
||||||
|
COMPANY_CONFIRMED: 'Disponibilité confirmée',
|
||||||
|
DOCUMENTS_REQUIRED: 'Documents conducteur manquants',
|
||||||
|
PAYMENT_REQUIRED: 'Paiement ou dépôt en attente',
|
||||||
|
BOOKING_CONFIRMED: 'Prêt à avancer',
|
||||||
|
CANCELLED: 'Demande clôturée',
|
||||||
|
},
|
||||||
|
missingItems: {
|
||||||
|
DATE_OF_BIRTH: 'Date de naissance',
|
||||||
|
IDENTITY_DOCUMENT: 'Pièce d’identité',
|
||||||
|
FULL_ADDRESS: 'Adresse complète',
|
||||||
|
DRIVER_LICENSE: 'Numéro du permis',
|
||||||
|
LICENSE_IMAGE: 'Image du permis',
|
||||||
|
LICENSE_APPROVAL: 'Validation du permis',
|
||||||
|
},
|
||||||
noLicenseImage: 'Aucune image de permis téléversée.',
|
noLicenseImage: 'Aucune image de permis téléversée.',
|
||||||
pickupPhotosTitle: 'Photos de départ',
|
pickupPhotosTitle: 'Photos de départ',
|
||||||
dropoffPhotosTitle: 'Photos de retour',
|
dropoffPhotosTitle: 'Photos de retour',
|
||||||
@@ -216,6 +259,24 @@ const detailCopy = {
|
|||||||
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
|
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
|
||||||
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
|
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
|
||||||
licenseImageLabel: 'صورة الرخصة',
|
licenseImageLabel: 'صورة الرخصة',
|
||||||
|
bookingProgress: 'تقدم الحجز',
|
||||||
|
paymentPending: 'الدفع أو العربون ما زال معلقاً',
|
||||||
|
stageLabels: {
|
||||||
|
REQUEST_SENT: 'بانتظار مراجعة الشركة',
|
||||||
|
COMPANY_CONFIRMED: 'تم تأكيد التوفر',
|
||||||
|
DOCUMENTS_REQUIRED: 'وثائق السائق ناقصة',
|
||||||
|
PAYMENT_REQUIRED: 'الدفع أو العربون معلق',
|
||||||
|
BOOKING_CONFIRMED: 'جاهز للمتابعة',
|
||||||
|
CANCELLED: 'تم إغلاق الطلب',
|
||||||
|
},
|
||||||
|
missingItems: {
|
||||||
|
DATE_OF_BIRTH: 'تاريخ الميلاد',
|
||||||
|
IDENTITY_DOCUMENT: 'وثيقة الهوية',
|
||||||
|
FULL_ADDRESS: 'العنوان الكامل',
|
||||||
|
DRIVER_LICENSE: 'رقم الرخصة',
|
||||||
|
LICENSE_IMAGE: 'صورة الرخصة',
|
||||||
|
LICENSE_APPROVAL: 'اعتماد الرخصة',
|
||||||
|
},
|
||||||
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
|
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
|
||||||
pickupPhotosTitle: 'صور الاستلام',
|
pickupPhotosTitle: 'صور الاستلام',
|
||||||
dropoffPhotosTitle: 'صور الإرجاع',
|
dropoffPhotosTitle: 'صور الإرجاع',
|
||||||
@@ -534,6 +595,31 @@ export default function ReservationDetailPage() {
|
|||||||
|
|
||||||
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
|
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
|
||||||
|
|
||||||
|
{reservation.source === 'MARKETPLACE' ? (
|
||||||
|
<div className="card p-6">
|
||||||
|
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingProgress}</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="badge-gray">
|
||||||
|
{copy.stageLabels[reservation.bookingRequest.stage as keyof typeof copy.stageLabels] ?? reservation.bookingRequest.stage}
|
||||||
|
</span>
|
||||||
|
{reservation.bookingRequest.paymentRequired ? (
|
||||||
|
<span className="rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
|
||||||
|
{copy.paymentPending}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{reservation.bookingRequest.documentsMissing.length > 0 ? (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{reservation.bookingRequest.documentsMissing.map((item) => (
|
||||||
|
<span key={item} className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
|
||||||
|
{copy.missingItems[item as keyof typeof copy.missingItems] ?? item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
|
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,10 @@ describe('marketplace API helpers', () => {
|
|||||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||||
|
|
||||||
await expect(marketplaceFetch('/marketplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
|
await expect(marketplaceFetch('/marketplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/marketplace\/vehicles$/), { cache: 'no-store' })
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/marketplace\/vehicles$/), {
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws rich marketplace API errors', async () => {
|
it('throws rich marketplace API errors', async () => {
|
||||||
@@ -53,6 +56,7 @@ describe('marketplace API helpers', () => {
|
|||||||
await expect(marketplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
|
await expect(marketplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
|
||||||
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
|
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ vehicleId: 'vehicle_1' }),
|
body: JSON.stringify({ vehicleId: 'vehicle_1' }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ export class MarketplaceApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function marketplaceFetch<T>(path: string): Promise<T> {
|
export async function marketplaceFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'include',
|
||||||
|
...init,
|
||||||
|
})
|
||||||
const json = await res.json().catch(() => null)
|
const json = await res.json().catch(() => null)
|
||||||
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||||
return json.data as T
|
return json.data as T
|
||||||
@@ -36,6 +40,7 @@ export async function marketplacePost<T>(path: string, body: unknown): Promise<T
|
|||||||
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||||
const res = await fetch(`${base}${path}`, {
|
const res = await fetch(`${base}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Car Booking Process Revision and Improvement Plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current renter booking process is too heavy too early. It asks for identity documents, full address, and detailed license data before the renter has even submitted a reservation request. That creates unnecessary friction, lowers conversion, and makes the flow feel like paperwork instead of booking.
|
||||||
|
|
||||||
|
## Revision Applied
|
||||||
|
|
||||||
|
The booking form has been simplified into a low-friction request flow:
|
||||||
|
|
||||||
|
1. Trip details first: pick-up location, drop-off choice, pick-up date, return date, and estimated total.
|
||||||
|
2. Contact details second: first name, last name, email, phone, and optional notes.
|
||||||
|
3. Driver details moved into an optional collapsible section.
|
||||||
|
4. Submission copy changed from a formal “reservation request” flow to a faster “request this car in 1 minute” flow.
|
||||||
|
5. Required validation reduced to only what is necessary to start the booking conversation: dates, name, email, phone, and required locations.
|
||||||
|
6. Optional driver fields are still supported and sent to the same backend if the renter chooses to provide them.
|
||||||
|
7. The existing backend contract is preserved because those identity and license fields are already optional in the public reservation schema.
|
||||||
|
|
||||||
|
## Why This Is Better
|
||||||
|
|
||||||
|
The old flow forced renters to provide high-trust personal data before they had enough confidence that the booking was worth completing. That is backwards. The revised flow asks only for the information needed to check availability and contact the renter. Driver documents can be collected after the company confirms availability, pricing, and intent.
|
||||||
|
|
||||||
|
This reduces cognitive load, reduces privacy anxiety, and makes mobile completion more realistic. Yes, apparently people abandon forms when confronted with a small government census inside a car card. Shocking.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Frontend Simplification
|
||||||
|
|
||||||
|
Replace the current booking form with the revised version in:
|
||||||
|
|
||||||
|
`apps/marketplace/src/components/BookingForm.tsx`
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The visible default form fits into three clear sections: trip details, contact details, optional driver details.
|
||||||
|
- Driver identity and license fields are hidden by default.
|
||||||
|
- Required fields are only trip dates, renter name, email, phone, and required locations.
|
||||||
|
- Estimated total still updates when valid dates are entered.
|
||||||
|
- Existing helper behavior for total days, return location, and optional text normalization remains intact.
|
||||||
|
|
||||||
|
### Phase 2: Availability Confidence
|
||||||
|
|
||||||
|
Add a lightweight availability check before or during submission.
|
||||||
|
|
||||||
|
Recommended behavior:
|
||||||
|
|
||||||
|
- When dates are selected, call an availability endpoint for the selected vehicle and date range.
|
||||||
|
- If unavailable, show the next available date before the renter fills contact details.
|
||||||
|
- If available, show a small “Likely available” message, without overpromising final confirmation.
|
||||||
|
|
||||||
|
Backend candidate:
|
||||||
|
|
||||||
|
- Add or expose `GET /marketplace/:slug/vehicles/:id/availability?startDate=&endDate=`.
|
||||||
|
- Reuse the existing `getVehicleAvailabilitySummary` service instead of inventing yet another inconsistent truth source, because apparently systems enjoy lying to themselves.
|
||||||
|
|
||||||
|
### Phase 3: Booking Status Transparency
|
||||||
|
|
||||||
|
After submission, show exactly what happens next.
|
||||||
|
|
||||||
|
Suggested post-submit states:
|
||||||
|
|
||||||
|
- Request sent.
|
||||||
|
- Company confirms availability.
|
||||||
|
- Renter provides documents if required.
|
||||||
|
- Payment/deposit is completed.
|
||||||
|
- Booking confirmed.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The renter understands that the first submission is a request, not a guaranteed reservation.
|
||||||
|
- Staff can see incomplete document status inside the dashboard.
|
||||||
|
- Reminder notifications are triggered if documents or payment are missing.
|
||||||
|
|
||||||
|
### Phase 4: Save Renter Profile
|
||||||
|
|
||||||
|
For logged-in renters, prefill known data.
|
||||||
|
|
||||||
|
Recommended behavior:
|
||||||
|
|
||||||
|
- Prefill name, email, and phone from the renter profile.
|
||||||
|
- Let renters save driver details to their account after submitting once.
|
||||||
|
- Never require repeated entry of license and identity fields for returning renters unless expired or missing.
|
||||||
|
|
||||||
|
### Phase 5: Measure the Flow
|
||||||
|
|
||||||
|
Track funnel metrics so the team stops guessing, a radical concept.
|
||||||
|
|
||||||
|
Recommended events:
|
||||||
|
|
||||||
|
- `booking_form_viewed`
|
||||||
|
- `trip_dates_selected`
|
||||||
|
- `contact_details_started`
|
||||||
|
- `driver_details_expanded`
|
||||||
|
- `booking_request_submitted`
|
||||||
|
- `booking_request_failed`
|
||||||
|
- `booking_request_success`
|
||||||
|
|
||||||
|
Primary KPIs:
|
||||||
|
|
||||||
|
- Form completion rate.
|
||||||
|
- Time to submit.
|
||||||
|
- Drop-off rate before contact details.
|
||||||
|
- Drop-off rate after driver details expansion.
|
||||||
|
- Request-to-confirmation conversion.
|
||||||
|
|
||||||
|
## Future UX Improvements
|
||||||
|
|
||||||
|
- Add date and time selection, not just dates, because rental handoff depends on time.
|
||||||
|
- Add promo code validation inside the vehicle page, not only search.
|
||||||
|
- Add a sticky mobile bottom bar with price estimate and submit button.
|
||||||
|
- Add a progress indicator: Trip → Contact → Optional documents.
|
||||||
|
- Add WhatsApp fallback after successful submission for companies that rely on chat.
|
||||||
|
- Support document upload after confirmation rather than typing document numbers into a tiny form.
|
||||||
|
|
||||||
|
## Risk Notes
|
||||||
|
|
||||||
|
- Do not remove backend support for driver fields. Some companies may need them for compliance or faster approvals.
|
||||||
|
- Do not make the first request feel like a guaranteed booking unless inventory locking and payment are implemented.
|
||||||
|
- Do not collect sensitive identity data unless there is a clear business reason and proper privacy handling.
|
||||||
|
- Do not bury the total price. Price uncertainty kills trust faster than bad button colors, though humans keep trying both.
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
# Plan: Fix and Simplify Car Booking Without Adding Customer Accounts
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Improve the car booking process while keeping the current guest booking model.
|
||||||
|
|
||||||
|
This plan does **not** add:
|
||||||
|
|
||||||
|
- Customer accounts
|
||||||
|
- Customer login
|
||||||
|
- Renter dashboard
|
||||||
|
- Global customer profile
|
||||||
|
- Cross-company customer sharing
|
||||||
|
- Saved customer identity
|
||||||
|
|
||||||
|
The goal is to make booking smoother, safer, and simpler while keeping each company’s customer data private.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Direction
|
||||||
|
|
||||||
|
The system should continue using this structure:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Company
|
||||||
|
└── Customer
|
||||||
|
└── Reservation
|
||||||
|
```
|
||||||
|
|
||||||
|
A customer who books with Company A exists as a customer under Company A.
|
||||||
|
|
||||||
|
If the same person books with Company B, they become a separate customer under Company B.
|
||||||
|
|
||||||
|
This is the correct approach for now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Privacy Rule
|
||||||
|
|
||||||
|
Customer data must always belong to one company.
|
||||||
|
|
||||||
|
The system must never allow Company A to view, edit, search, or infer customer data from Company B.
|
||||||
|
|
||||||
|
Correct model:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Company A
|
||||||
|
└── john@example.com
|
||||||
|
|
||||||
|
Company B
|
||||||
|
└── john@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
These are two separate customer records.
|
||||||
|
|
||||||
|
Do **not** create a shared global customer record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Keep Guest Booking Only
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Do not build customer accounts now.
|
||||||
|
|
||||||
|
Booking should work without login.
|
||||||
|
|
||||||
|
The customer only submits the information needed to request a booking.
|
||||||
|
|
||||||
|
### Required Behavior
|
||||||
|
|
||||||
|
When a customer books a car:
|
||||||
|
|
||||||
|
1. Customer selects a car.
|
||||||
|
2. Customer enters basic booking and contact information.
|
||||||
|
3. Backend finds the car.
|
||||||
|
4. Backend derives the company from the car.
|
||||||
|
5. Backend finds or creates a customer under that company.
|
||||||
|
6. Backend creates a reservation under that company.
|
||||||
|
|
||||||
|
The customer should not need to create an account to make a booking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Simplify the Booking Form
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
The current booking process asks for too much information too early.
|
||||||
|
|
||||||
|
That creates friction and makes the booking feel heavier than it needs to be.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
The first booking form should only ask for:
|
||||||
|
|
||||||
|
- Name
|
||||||
|
- Email
|
||||||
|
- Phone
|
||||||
|
- Pickup date
|
||||||
|
- Return date
|
||||||
|
- Pickup location
|
||||||
|
- Return location
|
||||||
|
- Optional message
|
||||||
|
|
||||||
|
### Move These Fields Out of the First Step
|
||||||
|
|
||||||
|
Do not require these during the initial booking request:
|
||||||
|
|
||||||
|
- Driver license
|
||||||
|
- License expiration date
|
||||||
|
- Government ID
|
||||||
|
- Passport
|
||||||
|
- Full address
|
||||||
|
- Document upload
|
||||||
|
- Payment details
|
||||||
|
|
||||||
|
These can be requested later during verification or approval.
|
||||||
|
|
||||||
|
### New Booking Flow
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Step 1: Request Booking
|
||||||
|
- Customer contact info
|
||||||
|
- Trip dates
|
||||||
|
- Pickup and return locations
|
||||||
|
- Notes
|
||||||
|
|
||||||
|
Step 2: Company Reviews Request
|
||||||
|
- Company checks availability
|
||||||
|
- Company accepts, rejects, or asks for more details
|
||||||
|
|
||||||
|
Step 3: Verification If Needed
|
||||||
|
- License
|
||||||
|
- Address
|
||||||
|
- Documents
|
||||||
|
- Payment
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the first action lightweight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Derive Company Ownership From the Car
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
The frontend should not decide which company owns a reservation.
|
||||||
|
|
||||||
|
If the frontend sends `companyId`, it can be wrong, manipulated, or stale.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
The backend must derive `companyId` from the selected car.
|
||||||
|
|
||||||
|
Correct backend logic:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const car = await prisma.car.findUnique({
|
||||||
|
where: { id: input.carId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!car) {
|
||||||
|
throw new Error("Car not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = car.companyId;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use that `companyId` for both the customer and reservation.
|
||||||
|
|
||||||
|
The frontend may send `carId`, but the backend decides the company.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Find or Create Customer by Company and Email
|
||||||
|
|
||||||
|
### Required Rule
|
||||||
|
|
||||||
|
Customers should be unique inside each company, not globally.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
@@unique([companyId, email])
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
john@example.com under Company A
|
||||||
|
john@example.com under Company B
|
||||||
|
```
|
||||||
|
|
||||||
|
without mixing their records.
|
||||||
|
|
||||||
|
### Booking Logic
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const normalizedEmail = input.email.toLowerCase().trim();
|
||||||
|
|
||||||
|
const customer = await prisma.customer.upsert({
|
||||||
|
where: {
|
||||||
|
companyId_email: {
|
||||||
|
companyId,
|
||||||
|
email: normalizedEmail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: input.name,
|
||||||
|
phone: input.phone,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
companyId,
|
||||||
|
email: normalizedEmail,
|
||||||
|
name: input.name,
|
||||||
|
phone: input.phone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important
|
||||||
|
|
||||||
|
Do not use email alone to find a customer.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
where: {
|
||||||
|
email: input.email
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
where: {
|
||||||
|
companyId_email: {
|
||||||
|
companyId,
|
||||||
|
email: normalizedEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Create Reservation With Company Scope
|
||||||
|
|
||||||
|
Every reservation must be linked to:
|
||||||
|
|
||||||
|
- `companyId`
|
||||||
|
- `customerId`
|
||||||
|
- `carId`
|
||||||
|
|
||||||
|
Recommended reservation creation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const reservation = await prisma.reservation.create({
|
||||||
|
data: {
|
||||||
|
companyId,
|
||||||
|
customerId: customer.id,
|
||||||
|
carId: car.id,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
pickupLocation: input.pickupLocation,
|
||||||
|
returnLocation: input.returnLocation,
|
||||||
|
notes: input.notes,
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial reservation status should be:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
PENDING
|
||||||
|
```
|
||||||
|
|
||||||
|
The company can later change it to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
CANCELLED
|
||||||
|
COMPLETED
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Enforce Company Privacy in Dashboard Queries
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
If company dashboard routes fetch customer or reservation records only by ID, one company may accidentally access another company’s data.
|
||||||
|
|
||||||
|
That is a serious multi-tenant privacy bug.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Every company-facing query must include `companyId`.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await prisma.customer.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await prisma.customer.findFirst({
|
||||||
|
where: {
|
||||||
|
id: customerId,
|
||||||
|
companyId: currentCompany.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await prisma.reservation.findUnique({
|
||||||
|
where: { id: reservationId },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await prisma.reservation.findFirst({
|
||||||
|
where: {
|
||||||
|
id: reservationId,
|
||||||
|
companyId: currentCompany.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply This Rule To
|
||||||
|
|
||||||
|
- Customers
|
||||||
|
- Reservations
|
||||||
|
- Cars
|
||||||
|
- Documents
|
||||||
|
- Notes
|
||||||
|
- Payments
|
||||||
|
- Messages
|
||||||
|
- Invoices
|
||||||
|
- Reviews
|
||||||
|
|
||||||
|
Anything owned by a company must be queried with company scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Prevent Cross-Company Search
|
||||||
|
|
||||||
|
Company staff should never be able to search all customers globally.
|
||||||
|
|
||||||
|
Company customer search must always filter by the company.
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await prisma.customer.findMany({
|
||||||
|
where: {
|
||||||
|
companyId: currentCompany.id,
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
|
{ email: { contains: search, mode: "insensitive" } },
|
||||||
|
{ phone: { contains: search, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await prisma.customer.findMany({
|
||||||
|
where: {
|
||||||
|
email: { contains: search },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The second version searches across companies and should not exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Improve Booking Status Feedback
|
||||||
|
|
||||||
|
After submission, show a clear confirmation page.
|
||||||
|
|
||||||
|
The customer should see:
|
||||||
|
|
||||||
|
- Booking reference
|
||||||
|
- Car name
|
||||||
|
- Pickup date
|
||||||
|
- Return date
|
||||||
|
- Status: Pending
|
||||||
|
- Company name
|
||||||
|
- Message: The company will review your request.
|
||||||
|
|
||||||
|
Do not leave the customer wondering whether the booking worked.
|
||||||
|
|
||||||
|
### Optional Confirmation Email
|
||||||
|
|
||||||
|
Send a confirmation email with:
|
||||||
|
|
||||||
|
- Booking reference
|
||||||
|
- Car details
|
||||||
|
- Dates
|
||||||
|
- Company name
|
||||||
|
- Status
|
||||||
|
- Support or contact information
|
||||||
|
|
||||||
|
No customer account required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Add Booking Reference
|
||||||
|
|
||||||
|
Add a public-safe booking reference to reservations.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
BK-2026-8F3K2
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for emails, support, and later booking lookup.
|
||||||
|
|
||||||
|
Recommended field:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
bookingReference String @unique
|
||||||
|
```
|
||||||
|
|
||||||
|
The reference should not expose database IDs.
|
||||||
|
|
||||||
|
Bad reference:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
cm8x9customeridreservationid
|
||||||
|
```
|
||||||
|
|
||||||
|
Good reference:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
BK-7H92KQ
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: Optional Booking Lookup Without Account
|
||||||
|
|
||||||
|
If needed later, allow customers to check booking status without creating an account.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
booking reference + email
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Booking Reference: BK-7H92KQ
|
||||||
|
Email: john@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend should only return limited information:
|
||||||
|
|
||||||
|
- Status
|
||||||
|
- Car name
|
||||||
|
- Company name
|
||||||
|
- Pickup date
|
||||||
|
- Return date
|
||||||
|
- Pickup location
|
||||||
|
- Return location
|
||||||
|
|
||||||
|
Do not expose:
|
||||||
|
|
||||||
|
- Internal notes
|
||||||
|
- Admin comments
|
||||||
|
- Risk flags
|
||||||
|
- Documents
|
||||||
|
- Payment metadata
|
||||||
|
- Other bookings
|
||||||
|
- Other company records
|
||||||
|
|
||||||
|
This gives convenience without building full accounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11: Add Validation
|
||||||
|
|
||||||
|
Validate booking input on both frontend and backend.
|
||||||
|
|
||||||
|
### Required Validations
|
||||||
|
|
||||||
|
- Email must be valid.
|
||||||
|
- Phone must be valid enough for contact.
|
||||||
|
- Start date must be before end date.
|
||||||
|
- Start date cannot be in the past.
|
||||||
|
- Car must exist.
|
||||||
|
- Car must be active/listed.
|
||||||
|
- Car must belong to an active company.
|
||||||
|
|
||||||
|
### Date Validation
|
||||||
|
|
||||||
|
Reject:
|
||||||
|
|
||||||
|
- Return date before pickup date.
|
||||||
|
- Same-day invalid ranges if not allowed.
|
||||||
|
- Past pickup dates.
|
||||||
|
|
||||||
|
### Availability Validation
|
||||||
|
|
||||||
|
Before creating a reservation, check whether the car already has an overlapping approved or pending reservation.
|
||||||
|
|
||||||
|
Example logic:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const overlappingReservation = await prisma.reservation.findFirst({
|
||||||
|
where: {
|
||||||
|
carId: car.id,
|
||||||
|
status: {
|
||||||
|
in: ["PENDING", "APPROVED"],
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
lt: input.endDate,
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
gt: input.startDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (overlappingReservation) {
|
||||||
|
throw new Error("Car is not available for the selected dates");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This avoids double booking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 12: Add Tests
|
||||||
|
|
||||||
|
Add tests for the booking flow and company privacy.
|
||||||
|
|
||||||
|
### Test 1: Booking creates company-scoped customer
|
||||||
|
|
||||||
|
Given:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Company A
|
||||||
|
Car A belongs to Company A
|
||||||
|
Customer books Car A
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer is created under Company A
|
||||||
|
Reservation is created under Company A
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Same email can book with two companies
|
||||||
|
|
||||||
|
Given:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
john@example.com books with Company A
|
||||||
|
john@example.com books with Company B
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Two customer records exist
|
||||||
|
One under Company A
|
||||||
|
One under Company B
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Company cannot view another company’s customer
|
||||||
|
|
||||||
|
Given:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer belongs to Company B
|
||||||
|
Company A user tries to fetch that customer
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
404 or 403
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 4: Company cannot view another company’s reservation
|
||||||
|
|
||||||
|
Given:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Reservation belongs to Company B
|
||||||
|
Company A user tries to fetch it
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
404 or 403
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 5: Backend ignores frontend companyId
|
||||||
|
|
||||||
|
Given:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Frontend sends carId for Company A
|
||||||
|
Frontend also sends companyId for Company B
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Reservation is created under Company A
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend must trust the car ownership, not the frontend payload.
|
||||||
|
|
||||||
|
### Test 6: Double booking is blocked
|
||||||
|
|
||||||
|
Given:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Car has an approved reservation from June 10 to June 15
|
||||||
|
Customer tries to book June 12 to June 14
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Booking is rejected
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 13: Implementation Order
|
||||||
|
|
||||||
|
1. Audit current booking form.
|
||||||
|
2. Classify fields as required now, optional now, move to verification, or remove.
|
||||||
|
3. Simplify the frontend booking form.
|
||||||
|
4. Update booking API to accept `carId` and derive `companyId` from the car.
|
||||||
|
5. Normalize email before lookup or storage.
|
||||||
|
6. Find or create customer using `companyId + email`.
|
||||||
|
7. Create reservation with `companyId + customerId + carId`.
|
||||||
|
8. Set initial reservation status to `PENDING`.
|
||||||
|
9. Audit company dashboard routes.
|
||||||
|
10. Add `companyId` filtering to every company-owned query.
|
||||||
|
11. Prevent cross-company customer and reservation search.
|
||||||
|
12. Add reservation availability check.
|
||||||
|
13. Add booking confirmation page.
|
||||||
|
14. Add confirmation email if email infrastructure exists.
|
||||||
|
15. Add booking reference if not already present.
|
||||||
|
16. Add tests for booking creation, company isolation, and double-booking prevention.
|
||||||
|
17. Deploy carefully.
|
||||||
|
18. Run migration only if schema changes are needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Not To Change Now
|
||||||
|
|
||||||
|
Do not add:
|
||||||
|
|
||||||
|
- Renter model usage
|
||||||
|
- Customer login
|
||||||
|
- Customer password
|
||||||
|
- Customer dashboard
|
||||||
|
- Shared profile
|
||||||
|
- Cross-company booking history
|
||||||
|
- Saved documents
|
||||||
|
- Saved payment methods
|
||||||
|
|
||||||
|
Do not change customer uniqueness to global email.
|
||||||
|
|
||||||
|
Do not let companies search customers outside their own company.
|
||||||
|
|
||||||
|
Do not trust `companyId` from the frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
This work is complete when:
|
||||||
|
|
||||||
|
- A customer can book without creating an account.
|
||||||
|
- The booking form is shorter and easier to complete.
|
||||||
|
- Customers are created per company using `companyId + email`.
|
||||||
|
- The same email can exist under multiple companies.
|
||||||
|
- Reservations are always tied to the correct company.
|
||||||
|
- The backend derives `companyId` from `carId`.
|
||||||
|
- Company dashboards cannot access another company’s customers.
|
||||||
|
- Company dashboards cannot access another company’s reservations.
|
||||||
|
- Overlapping bookings are blocked.
|
||||||
|
- Booking confirmation is clear.
|
||||||
|
- Sensitive information is not collected too early.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Recommendation
|
||||||
|
|
||||||
|
Keep the system guest-based for now.
|
||||||
|
|
||||||
|
Fix the booking process by simplifying the form, enforcing company-scoped data access, deriving company ownership from the car, and preventing double bookings.
|
||||||
|
|
||||||
|
This gives the product a cleaner booking process without adding customer accounts before they are actually needed.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# User Input Validation and Formatting Plan
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Global Rules](#global-rules)
|
||||||
|
2. [Field-Specific Rules](#field-specific-rules)
|
||||||
|
3. [Implementation Logic](#implementation-logic)
|
||||||
|
4. [Formatting Functions](#formatting-functions)
|
||||||
|
5. [Error Handling](#error-handling)
|
||||||
|
6. [Processing Order](#processing-order)
|
||||||
|
7. [Field Configuration Reference](#field-configuration-reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Rules
|
||||||
|
|
||||||
|
### Allowed Characters
|
||||||
|
All fields accept only:
|
||||||
|
- Letters (`A-Z`, `a-z`)
|
||||||
|
- Numbers (`0-9`)
|
||||||
|
- Special characters: `@`, `-`, `/`
|
||||||
|
- Spaces (where applicable)
|
||||||
|
|
||||||
|
**Global Regex Pattern:**
|
||||||
|
```regex
|
||||||
|
^[a-zA-Z0-9@\-\/\s]*$
|
||||||
|
Field-Specific Rules
|
||||||
|
Group 1: Title Case Fields (Uppercase First Letter, Lowercase Rest)
|
||||||
|
Fields: Name, Nationality, Street Address, City, Pick-up Location, Return Location, Country
|
||||||
|
|
||||||
|
Property Value
|
||||||
|
Format Uppercase first letter, lowercase rest
|
||||||
|
Max Characters 50 (Name, Nationality, City, Locations, Country) / 100 (Street Address)
|
||||||
|
Allowed Characters Letters, spaces, - (Street Address also allows numbers and /)
|
||||||
|
Transformation toTitleCase()
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
"john doe" → "John Doe"
|
||||||
|
|
||||||
|
"MARIA" → "Maria"
|
||||||
|
|
||||||
|
"new york" → "New York"
|
||||||
|
|
||||||
|
"123 main st." → "123 Main St."
|
||||||
|
|
||||||
|
Group 2: Title Case All Words Fields
|
||||||
|
Fields: Full Address, Commercial Name, Legal Company Name
|
||||||
|
|
||||||
|
Property Value
|
||||||
|
Format Uppercase first letter of every word
|
||||||
|
Max Characters 200 (Full Address) / 100 (Company Names)
|
||||||
|
Allowed Characters Letters, numbers, spaces, -, /, ,
|
||||||
|
Transformation toTitleCaseAll()
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
"123 main street, apt 4b" → "123 Main Street, Apt 4b"
|
||||||
|
|
||||||
|
"acme corporation" → "Acme Corporation"
|
||||||
|
|
||||||
|
Group 3: Lowercase Fields
|
||||||
|
Fields: Email
|
||||||
|
|
||||||
|
Property Value
|
||||||
|
Format All lowercase
|
||||||
|
Max Characters 100
|
||||||
|
Allowed Characters Letters, numbers, @, ., _, %, +, -
|
||||||
|
Transformation toLowerCase()
|
||||||
|
Validation Must match email format
|
||||||
|
Validation Regex:
|
||||||
|
|
||||||
|
regex
|
||||||
|
^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
"John.Doe@Example.COM" → "john.doe@example.com"
|
||||||
|
|
||||||
|
Group 4: Uppercase Fields (Letters Only)
|
||||||
|
Fields: License Plate, VIN, CIN, Passport Number, International Permit Number, Driver License Number, License Category, ICE Number, Commercial Registry (RC)
|
||||||
|
|
||||||
|
Property Value
|
||||||
|
Format ALL UPPERCASE letters, numbers unchanged
|
||||||
|
Max Characters 30
|
||||||
|
Allowed Characters Letters and numbers only
|
||||||
|
Transformation toUpperCase()
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
"abc-123" → "ABC-123"
|
||||||
|
|
||||||
|
"ab123cd" → "AB123CD"
|
||||||
|
|
||||||
|
Group 5: Car Fields
|
||||||
|
Fields: Car Mark, Car Model, Car Color
|
||||||
|
|
||||||
|
Property Value
|
||||||
|
Format Uppercase first letter, lowercase rest
|
||||||
|
Max Characters 30
|
||||||
|
Allowed Characters Letters, numbers, spaces, -
|
||||||
|
Transformation toTitleCase()
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
"TOYOTA" → "Toyota"
|
||||||
|
|
||||||
|
"midnight-blue" → "Midnight-Blue"
|
||||||
|
|
||||||
|
Group 6: Preserved Format Fields
|
||||||
|
Fields: ZIP Code
|
||||||
|
|
||||||
|
Property Value
|
||||||
|
Format As entered (no case transformation)
|
||||||
|
Max Characters 10
|
||||||
|
Allowed Characters Letters, numbers, -
|
||||||
|
Transformation preserveOriginal()
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
"12345" → "12345"
|
||||||
|
|
||||||
|
"SW1A 1AA" → "SW1A 1AA"
|
||||||
|
|
||||||
|
Implementation Logic
|
||||||
|
Main Processing Function
|
||||||
|
javascript
|
||||||
|
function sanitizeAndFormatInput(value, fieldType) {
|
||||||
|
// Step 1: Remove disallowed characters
|
||||||
|
let sanitized = removeDisallowedChars(value, fieldType);
|
||||||
|
|
||||||
|
// Step 2: Trim leading/trailing whitespace
|
||||||
|
sanitized = sanitized.trim();
|
||||||
|
|
||||||
|
// Step 3: Validate format (if applicable)
|
||||||
|
if (fieldType === 'email' && !isValidEmail(sanitized)) {
|
||||||
|
throw new Error('Invalid email format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Apply field-specific formatting
|
||||||
|
let formatted = applyFormatting(sanitized, fieldType);
|
||||||
|
|
||||||
|
// Step 5: Truncate to max length
|
||||||
|
formatted = truncateToMaxLength(formatted, fieldType);
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
Processing Order
|
||||||
|
Sanitize — Remove invalid characters
|
||||||
|
|
||||||
|
Trim — Remove leading/trailing whitespace
|
||||||
|
|
||||||
|
Validate — Check format (email, required fields)
|
||||||
|
|
||||||
|
Format — Apply case transformation
|
||||||
|
|
||||||
|
Truncate — Enforce max length
|
||||||
|
|
||||||
|
Return — Output formatted value
|
||||||
|
|
||||||
|
Formatting Functions
|
||||||
|
Function Description Used For
|
||||||
|
toTitleCase() First letter uppercase, rest lowercase per word Name, Nationality, Street Address, City, Locations, Country, Car fields
|
||||||
|
toTitleCaseAll() First letter of every word uppercase Full Address, Commercial Name, Legal Company Name
|
||||||
|
toLowerCase() All characters to lowercase Email
|
||||||
|
toUpperCase() All letters to uppercase, numbers unchanged License Plate, VIN, CIN, Passport, Permit, Driver License, License Category, ICE, RC
|
||||||
|
preserveOriginal() No transformation ZIP Code
|
||||||
|
Function Implementations
|
||||||
|
javascript
|
||||||
|
function toTitleCase(str) {
|
||||||
|
return str.replace(/\b\w/g, char => char.toUpperCase())
|
||||||
|
.replace(/\b\w+\b/g, word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTitleCaseAll(str) {
|
||||||
|
return str.replace(/\b\w+\b/g, word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLowerCase(str) {
|
||||||
|
return str.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUpperCase(str) {
|
||||||
|
return str.replace(/[a-zA-Z]/g, char => char.toUpperCase());
|
||||||
|
}
|
||||||
|
Error Handling
|
||||||
|
Condition Error Message
|
||||||
|
Invalid characters "Only letters, numbers, @, -, and / are allowed"
|
||||||
|
Invalid email format "Please enter a valid email address"
|
||||||
|
Required field empty "This field is required"
|
||||||
|
Exceeds max length "Maximum [X] characters allowed"
|
||||||
|
Validation Flow
|
||||||
|
text
|
||||||
|
User Input
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Character Validation ────► Invalid ────► Error Message
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Format Validation ────► Invalid ────► Error Message
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Apply Transformation
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Check Max Length ────► Exceeds ────► Truncate / Warning
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Return Formatted Value
|
||||||
|
Field Configuration Reference
|
||||||
|
# Field Max Length Format Transformation
|
||||||
|
1 Name 50 Title Case toTitleCase()
|
||||||
|
2 Nationality 50 Title Case toTitleCase()
|
||||||
|
3 Street Address 100 Title Case toTitleCase()
|
||||||
|
4 City 50 Title Case toTitleCase()
|
||||||
|
5 Pick-up Location 50 Title Case toTitleCase()
|
||||||
|
6 Return Location 50 Title Case toTitleCase()
|
||||||
|
7 Country 50 Title Case toTitleCase()
|
||||||
|
8 Full Address 200 Title Case All toTitleCaseAll()
|
||||||
|
9 Email 100 Lowercase toLowerCase()
|
||||||
|
10 Car Mark 30 Title Case toTitleCase()
|
||||||
|
11 Car Model 30 Title Case toTitleCase()
|
||||||
|
12 Car Color 30 Title Case toTitleCase()
|
||||||
|
13 License Plate 30 Uppercase toUpperCase()
|
||||||
|
14 VIN 30 Uppercase toUpperCase()
|
||||||
|
15 CIN 30 Uppercase toUpperCase()
|
||||||
|
16 Passport Number 30 Uppercase toUpperCase()
|
||||||
|
17 International Permit Number 30 Uppercase toUpperCase()
|
||||||
|
18 Driver License Number 30 Uppercase toUpperCase()
|
||||||
|
19 License Category 30 Uppercase toUpperCase()
|
||||||
|
20 ICE Number 30 Uppercase toUpperCase()
|
||||||
|
21 Commercial Registry (RC) 30 Uppercase toUpperCase()
|
||||||
|
22 Commercial Name 100 Title Case All toTitleCaseAll()
|
||||||
|
23 Legal Company Name 100 Title Case All toTitleCaseAll()
|
||||||
|
24 ZIP Code 10 Preserved preserveOriginal()
|
||||||
|
Quick Reference Card
|
||||||
|
Email Validation
|
||||||
|
regex
|
||||||
|
^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$
|
||||||
|
Global Character Allowlist
|
||||||
|
regex
|
||||||
|
^[a-zA-Z0-9@\-\/\s]*$
|
||||||
|
Rules Summary
|
||||||
|
Group Fields Transformation
|
||||||
|
Title Case Name, Nationality, Address, City, Locations, Country "John Doe"
|
||||||
|
Title Case All Full Address, Company Names "Acme Corporation Ltd."
|
||||||
|
Lowercase Email "user@domain.com"
|
||||||
|
Uppercase IDs, Licenses, Documents "ABC123"
|
||||||
|
Preserved ZIP Code As entered
|
||||||
|
|
||||||
|
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
ALTER TYPE "NotificationType" ADD VALUE IF NOT EXISTS 'DOCUMENTS_REQUIRED';
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE IF NOT EXISTS 'PAYMENT_REQUIRED';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "marketplace_funnel_events" (
|
||||||
|
"id" TEXT PRIMARY KEY,
|
||||||
|
"eventName" TEXT NOT NULL,
|
||||||
|
"companyId" TEXT,
|
||||||
|
"companySlug" TEXT NOT NULL,
|
||||||
|
"vehicleId" TEXT NOT NULL,
|
||||||
|
"renterId" TEXT,
|
||||||
|
"sessionId" TEXT,
|
||||||
|
"path" TEXT,
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_eventName_createdAt_idx"
|
||||||
|
ON "marketplace_funnel_events"("eventName", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_companyId_createdAt_idx"
|
||||||
|
ON "marketplace_funnel_events"("companyId", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_companySlug_createdAt_idx"
|
||||||
|
ON "marketplace_funnel_events"("companySlug", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_vehicleId_createdAt_idx"
|
||||||
|
ON "marketplace_funnel_events"("vehicleId", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_renterId_createdAt_idx"
|
||||||
|
ON "marketplace_funnel_events"("renterId", "createdAt");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "reservations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "bookingReference" TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "reservations_bookingReference_key"
|
||||||
|
ON "reservations"("bookingReference");
|
||||||
@@ -422,6 +422,8 @@ enum NotificationType {
|
|||||||
BOOKING_CANCELLED
|
BOOKING_CANCELLED
|
||||||
PAYMENT_RECEIVED
|
PAYMENT_RECEIVED
|
||||||
PAYMENT_FAILED
|
PAYMENT_FAILED
|
||||||
|
DOCUMENTS_REQUIRED
|
||||||
|
PAYMENT_REQUIRED
|
||||||
SUBSCRIPTION_TRIAL_ENDING
|
SUBSCRIPTION_TRIAL_ENDING
|
||||||
SUBSCRIPTION_SUSPENDED
|
SUBSCRIPTION_SUSPENDED
|
||||||
VEHICLE_MAINTENANCE_DUE
|
VEHICLE_MAINTENANCE_DUE
|
||||||
@@ -1293,6 +1295,7 @@ model Reservation {
|
|||||||
notes String?
|
notes String?
|
||||||
cancelReason String?
|
cancelReason String?
|
||||||
cancelledBy CancelledBy?
|
cancelledBy CancelledBy?
|
||||||
|
bookingReference String? @unique
|
||||||
contractNumber String? @unique
|
contractNumber String? @unique
|
||||||
invoiceNumber String? @unique
|
invoiceNumber String? @unique
|
||||||
reviewToken String? @unique
|
reviewToken String? @unique
|
||||||
@@ -1345,6 +1348,26 @@ model ReservationPublicAccess {
|
|||||||
@@map("reservation_public_access")
|
@@map("reservation_public_access")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model MarketplaceFunnelEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventName String
|
||||||
|
companyId String?
|
||||||
|
companySlug String
|
||||||
|
vehicleId String
|
||||||
|
renterId String?
|
||||||
|
sessionId String?
|
||||||
|
path String?
|
||||||
|
metadata Json @default("{}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([eventName, createdAt])
|
||||||
|
@@index([companyId, createdAt])
|
||||||
|
@@index([companySlug, createdAt])
|
||||||
|
@@index([vehicleId, createdAt])
|
||||||
|
@@index([renterId, createdAt])
|
||||||
|
@@map("marketplace_funnel_events")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
model WebhookEvent {
|
model WebhookEvent {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
Vendored
+2
@@ -9,6 +9,8 @@ export type NotificationType =
|
|||||||
| 'BOOKING_CANCELLED'
|
| 'BOOKING_CANCELLED'
|
||||||
| 'PAYMENT_RECEIVED'
|
| 'PAYMENT_RECEIVED'
|
||||||
| 'PAYMENT_FAILED'
|
| 'PAYMENT_FAILED'
|
||||||
|
| 'DOCUMENTS_REQUIRED'
|
||||||
|
| 'PAYMENT_REQUIRED'
|
||||||
| 'SUBSCRIPTION_TRIAL_ENDING'
|
| 'SUBSCRIPTION_TRIAL_ENDING'
|
||||||
| 'SUBSCRIPTION_SUSPENDED'
|
| 'SUBSCRIPTION_SUSPENDED'
|
||||||
| 'VEHICLE_MAINTENANCE_DUE'
|
| 'VEHICLE_MAINTENANCE_DUE'
|
||||||
|
|||||||
Reference in New Issue
Block a user