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: {
|
||||
email: string
|
||||
firstName: string
|
||||
@@ -107,13 +118,45 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
export async function createMarketplaceReservation(data: {
|
||||
companyId: string; vehicleId: string; customerId: string
|
||||
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({
|
||||
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) {
|
||||
return prisma.company.findFirst({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as service from './marketplace.service'
|
||||
import {
|
||||
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
|
||||
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
|
||||
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
|
||||
} from './marketplace.schemas'
|
||||
|
||||
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) => {
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
|
||||
@@ -24,9 +24,14 @@ export const companiesQuerySchema = z.object({
|
||||
hasOffer: z.string().optional(),
|
||||
})
|
||||
|
||||
export const vehicleAvailabilityQuerySchema = z.object({
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const marketplaceReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100),
|
||||
companySlug: z.string().trim().max(100).optional(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
@@ -51,6 +56,23 @@ export const marketplaceReservationSchema = z.object({
|
||||
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({
|
||||
overallRating: z.number().int().min(1).max(5),
|
||||
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: {
|
||||
vehicleId: string; companySlug: string; firstName: string; lastName: string
|
||||
vehicleId: string; companySlug?: string; firstName: string; lastName: string
|
||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
||||
identityDocumentNumber?: string; fullAddress?: 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')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
|
||||
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||
@@ -185,8 +219,18 @@ export async function createMarketplaceReservation(body: {
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
const reservation = await repo.createMarketplaceReservation({
|
||||
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
|
||||
startDate, endDate, pickupLocation, returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
|
||||
companyId: vehicle.companyId,
|
||||
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
|
||||
@@ -214,7 +258,32 @@ export async function createMarketplaceReservation(body: {
|
||||
channels: ['IN_APP'],
|
||||
}).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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('./marketplace.repo', () => ({
|
||||
findListedCompanies: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleForMarketplace: vi.fn(),
|
||||
findVehicleForMarketplaceById: vi.fn(),
|
||||
upsertMarketplaceCustomer: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
findCompanyPage: vi.fn(),
|
||||
@@ -136,13 +137,14 @@ describe('createMarketplaceReservation', () => {
|
||||
}
|
||||
|
||||
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(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' })
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(result.bookingReference).toBe('BK-2026-ABC123')
|
||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
@@ -155,20 +157,20 @@ describe('createMarketplaceReservation', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
})
|
||||
|
||||
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(
|
||||
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
||||
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'
|
||||
|
||||
function buildPickupAddress(value: unknown) {
|
||||
@@ -47,7 +47,6 @@ async function assertLicenseCompliance(reservationId: string, companyId: string)
|
||||
export async function confirmReservation(id: string, companyId: string) {
|
||||
const reservation = await repo.findByIdSimple(id, companyId)
|
||||
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' })
|
||||
await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED')
|
||||
@@ -97,6 +96,44 @@ export async function confirmReservation(id: string, companyId: string) {
|
||||
},
|
||||
}).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
|
||||
}
|
||||
|
||||
|
||||
@@ -60,10 +60,19 @@ describe('reservation.presenter boundary behavior', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
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')
|
||||
@@ -75,10 +84,19 @@ describe('reservation.presenter boundary behavior', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
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()
|
||||
|
||||
@@ -19,12 +19,18 @@ describe('serializeReservationForDashboard', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation-1',
|
||||
status: 'CONFIRMED',
|
||||
source: 'MARKETPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
extras: {},
|
||||
customer: {
|
||||
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',
|
||||
licenseValidationStatus: 'APPROVED',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ export function normalizeOptionalString(value: string | null | undefined): strin
|
||||
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: {
|
||||
status: string
|
||||
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 {
|
||||
extras: unknown
|
||||
status: string
|
||||
source: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
paymentStatus?: string | null
|
||||
customer?: {
|
||||
id: string
|
||||
driverLicense?: string | null
|
||||
dateOfBirth?: Date | null
|
||||
address?: unknown
|
||||
licenseImageUrl?: string | null
|
||||
licenseValidationStatus?: string | 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 customer =
|
||||
reservation.customer
|
||||
@@ -59,5 +144,6 @@ export function serializeReservationForDashboard<T extends {
|
||||
...(customer !== undefined ? { customer } : {}),
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
workflow: buildReservationWorkflow(reservation),
|
||||
bookingRequest: buildBookingRequestProgress({ ...reservation, customer }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,13 @@ function maskPhone(phone?: string | null) {
|
||||
export function presentPublicBooking(reservation: any) {
|
||||
return {
|
||||
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,
|
||||
returnAt: reservation.endDate instanceof Date ? reservation.endDate.toISOString() : reservation.endDate,
|
||||
pickupLocation: reservation.pickupLocation ?? null,
|
||||
returnLocation: reservation.returnLocation ?? null,
|
||||
vehicle: {
|
||||
make: reservation.vehicle?.make,
|
||||
model: reservation.vehicle?.model,
|
||||
@@ -72,5 +76,6 @@ export function presentPublicBooking(reservation: any) {
|
||||
currency: 'MAD',
|
||||
},
|
||||
paymentStatus: reservation.paymentStatus,
|
||||
message: 'The company will review your request.',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,46 +45,53 @@ export async function upsertCustomer(companyId: string, data: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone: string
|
||||
driverLicense: string
|
||||
dateOfBirth: string
|
||||
licenseExpiry: string
|
||||
licenseIssuedAt: string
|
||||
nationality: string
|
||||
identityDocumentNumber: string
|
||||
fullAddress: string
|
||||
licenseCountry: string
|
||||
driverLicense?: string
|
||||
dateOfBirth?: string
|
||||
licenseExpiry?: string
|
||||
licenseIssuedAt?: string
|
||||
nationality?: string
|
||||
identityDocumentNumber?: string
|
||||
fullAddress?: string
|
||||
licenseCountry?: string
|
||||
licenseNumber?: string | null
|
||||
licenseCategory: string
|
||||
licenseCategory?: string
|
||||
internationalLicenseNumber?: string | null
|
||||
}) {
|
||||
const normalizedEmail = data.email.trim().toLowerCase()
|
||||
const existing = await prisma.customer.findUnique({
|
||||
where: { companyId_email: { companyId, email: data.email } },
|
||||
where: { companyId_email: { companyId, email: normalizedEmail } },
|
||||
select: { id: true, address: true },
|
||||
})
|
||||
|
||||
const address = {
|
||||
...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)
|
||||
? existing.address as Record<string, unknown>
|
||||
: {}),
|
||||
fullAddress: data.fullAddress,
|
||||
identityDocumentNumber: data.identityDocumentNumber,
|
||||
internationalLicenseNumber: data.internationalLicenseNumber ?? null,
|
||||
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 as Record<string, unknown>
|
||||
: {}),
|
||||
...addressPatch,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
email: normalizedEmail,
|
||||
phone: data.phone,
|
||||
driverLicense: data.driverLicense,
|
||||
dateOfBirth: new Date(data.dateOfBirth),
|
||||
nationality: data.nationality,
|
||||
address,
|
||||
licenseExpiry: new Date(data.licenseExpiry),
|
||||
licenseIssuedAt: new Date(data.licenseIssuedAt),
|
||||
licenseCountry: data.licenseCountry,
|
||||
licenseNumber: data.licenseNumber ?? data.driverLicense,
|
||||
licenseCategory: data.licenseCategory,
|
||||
...(data.driverLicense ? { driverLicense: data.driverLicense } : {}),
|
||||
...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}),
|
||||
...(data.nationality ? { nationality: data.nationality } : {}),
|
||||
...(address ? { address } : {}),
|
||||
...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}),
|
||||
...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}),
|
||||
...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}),
|
||||
...((data.licenseNumber ?? data.driverLicense) ? { licenseNumber: data.licenseNumber ?? data.driverLicense } : {}),
|
||||
...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}),
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
@@ -106,14 +113,14 @@ export async function createReservation(data: object) {
|
||||
export async function findReservationWithDetails(reservationId: string) {
|
||||
return prisma.reservation.findUniqueOrThrow({
|
||||
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) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
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',
|
||||
email: 'aya@example.com',
|
||||
phone: '+212600000000',
|
||||
driverLicense: 'DL-123',
|
||||
dateOfBirth: '1995-01-01T00:00:00.000Z',
|
||||
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',
|
||||
pickupLocation: 'Casablanca Airport',
|
||||
returnLocation: 'Casablanca Airport',
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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({
|
||||
name: 'Aya', email: 'aya@example.com', message: 'Hello',
|
||||
})
|
||||
|
||||
@@ -19,16 +19,18 @@ export const bookSchema = z.object({
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(1).max(30),
|
||||
driverLicense: z.string().min(1).max(50),
|
||||
dateOfBirth: z.string().datetime(),
|
||||
licenseExpiry: z.string().datetime(),
|
||||
licenseIssuedAt: z.string().datetime(),
|
||||
nationality: z.string().min(1).max(100),
|
||||
identityDocumentNumber: z.string().min(1).max(100),
|
||||
fullAddress: z.string().min(1).max(500),
|
||||
licenseCountry: z.string().min(1).max(100),
|
||||
pickupLocation: z.string().trim().min(1).max(100),
|
||||
returnLocation: z.string().trim().min(1).max(100),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
licenseCountry: z.string().min(1).max(100).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(),
|
||||
offerId: z.string().cuid().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/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('./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', () => ({
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
@@ -62,15 +68,8 @@ function bookingBody(overrides: Record<string, unknown> = {}) {
|
||||
lastName: 'Renter',
|
||||
email: 'nora@example.test',
|
||||
phone: '+212600000000',
|
||||
driverLicense: 'DL-1',
|
||||
dateOfBirth: '1992-01-01',
|
||||
licenseExpiry: '2027-01-01',
|
||||
licenseIssuedAt: '2022-01-01',
|
||||
nationality: 'MA',
|
||||
identityDocumentNumber: 'ID-1',
|
||||
fullAddress: 'Casablanca',
|
||||
licenseCountry: 'MA',
|
||||
licenseCategory: 'B',
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
...overrides,
|
||||
} 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.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.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({
|
||||
promoCodeUsed: 'FREEDAY',
|
||||
@@ -122,13 +137,16 @@ describe('site.service public booking/payment boundaries', () => {
|
||||
discountAmount: 500,
|
||||
pricingRulesTotal: 90,
|
||||
totalAmount: 1090,
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
status: 'DRAFT',
|
||||
source: 'PUBLIC_SITE',
|
||||
}))
|
||||
expect(applyInsurancesToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', ['insurance_1'], 3, 1500)
|
||||
expect(applyAdditionalDriversToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', [{ firstName: 'Second' }], 3)
|
||||
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1')
|
||||
expect(result.requiresManualApproval).toBe(true)
|
||||
expect(applyInsurancesToReservation).not.toHaveBeenCalled()
|
||||
expect(applyAdditionalDriversToReservation).not.toHaveBeenCalled()
|
||||
expect(validateAndFlagLicense).not.toHaveBeenCalled()
|
||||
expect(result.status).toBe('PENDING')
|
||||
expect(result.bookingReference).toBe('BK-2026-AB12CD')
|
||||
})
|
||||
|
||||
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 { AppError, NotFoundError } from '../../http/errors'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { applyInsurancesToReservation } from '../../services/insuranceService'
|
||||
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService'
|
||||
import { applyPricingRules } from '../../services/pricingRuleService'
|
||||
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
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() {
|
||||
return getMarketplaceHomepageContent()
|
||||
}
|
||||
@@ -132,8 +136,9 @@ export async function validatePromoCode(slug: string, code: string) {
|
||||
export async function createBooking(slug: string, body: {
|
||||
vehicleId: string; startDate: string; endDate: string
|
||||
firstName: string; lastName: string; email: string; phone: string
|
||||
driverLicense: string; dateOfBirth: string; licenseExpiry: string; licenseIssuedAt: string; nationality: string
|
||||
identityDocumentNumber: string; fullAddress: string; licenseCountry: string; licenseNumber?: string; licenseCategory: string; internationalLicenseNumber?: string
|
||||
pickupLocation: string; returnLocation: 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
|
||||
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
||||
}) {
|
||||
@@ -143,6 +148,7 @@ export async function createBooking(slug: string, body: {
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
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 } })
|
||||
if (!availability.available) {
|
||||
@@ -182,16 +188,10 @@ export async function createBooking(slug: string, body: {
|
||||
}
|
||||
}
|
||||
|
||||
const additionalDriversList = body.additionalDrivers ?? []
|
||||
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({
|
||||
companyId: company.id,
|
||||
vehicleId: vehicle.id,
|
||||
@@ -202,6 +202,8 @@ export async function createBooking(slug: string, body: {
|
||||
source: body.source ?? 'PUBLIC_SITE',
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
pickupLocation: body.pickupLocation,
|
||||
returnLocation: body.returnLocation,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
|
||||
@@ -209,20 +211,10 @@ export async function createBooking(slug: string, body: {
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal,
|
||||
notes: body.notes ?? null,
|
||||
bookingReference: generateBookingReference(),
|
||||
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()
|
||||
await repo.createReservationPublicAccess(
|
||||
reservation.id,
|
||||
@@ -232,11 +224,10 @@ export async function createBooking(slug: string, body: {
|
||||
|
||||
const refreshed = await repo.findReservationWithDetails(reservation.id)
|
||||
return {
|
||||
...refreshed,
|
||||
...presentPublicBooking({ ...refreshed, company }),
|
||||
publicAccessToken: publicAccess.token,
|
||||
requiresManualApproval:
|
||||
primaryLicenseResult.requiresApproval ||
|
||||
refreshed.additionalDrivers.some((d: any) => d.requiresApproval),
|
||||
bookingReference: (refreshed as any).bookingReference,
|
||||
message: 'The company will review your request.',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,9 +175,12 @@ describe('validatePromoCode', () => {
|
||||
describe('createBooking', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1',
|
||||
startDate: '2025-06-01T00:00:00.000Z',
|
||||
endDate: '2025-06-04T00:00:00.000Z',
|
||||
startDate: '2026-07-01T00:00:00.000Z',
|
||||
endDate: '2026-07-04T00:00:00.000Z',
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
phone: '+212600000000',
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -186,20 +189,36 @@ describe('createBooking', () => {
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
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.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)
|
||||
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 () => {
|
||||
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' })
|
||||
})
|
||||
|
||||
@@ -207,11 +226,6 @@ describe('createBooking', () => {
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
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
|
||||
vehicle: { make: string; model: string; year: number; licensePlate: string }
|
||||
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> = {
|
||||
@@ -42,6 +48,33 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
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 }) {
|
||||
const [reason, setReason] = useState('')
|
||||
return (
|
||||
@@ -237,6 +270,8 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {
|
||||
onDecline: () => void
|
||||
}) {
|
||||
const days = r.totalDays
|
||||
const missingItems = r.bookingRequest.documentsMissing.map((item) => MISSING_ITEM_LABEL[item] ?? item)
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden border-l-4 border-l-orange-400">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Right: total + actions */}
|
||||
|
||||
@@ -66,6 +66,13 @@ interface ReservationDetail {
|
||||
checkInInspectionEditable: boolean
|
||||
checkOutInspectionEditable: boolean
|
||||
}
|
||||
bookingRequest: {
|
||||
stage: string
|
||||
companyConfirmed: boolean
|
||||
documentsRequired: boolean
|
||||
paymentRequired: boolean
|
||||
documentsMissing: string[]
|
||||
}
|
||||
}
|
||||
|
||||
type EditMode = 'booking' | 'return' | null
|
||||
@@ -118,6 +125,24 @@ const detailCopy = {
|
||||
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
|
||||
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
|
||||
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.',
|
||||
pickupPhotosTitle: 'Pickup photos',
|
||||
dropoffPhotosTitle: 'Return photos',
|
||||
@@ -167,6 +192,24 @@ const detailCopy = {
|
||||
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.',
|
||||
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.',
|
||||
pickupPhotosTitle: 'Photos de départ',
|
||||
dropoffPhotosTitle: 'Photos de retour',
|
||||
@@ -216,6 +259,24 @@ const detailCopy = {
|
||||
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
|
||||
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
|
||||
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: 'لم يتم رفع صورة الرخصة.',
|
||||
pickupPhotosTitle: 'صور الاستلام',
|
||||
dropoffPhotosTitle: 'صور الإرجاع',
|
||||
@@ -534,6 +595,31 @@ export default function ReservationDetailPage() {
|
||||
|
||||
{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="card p-6">
|
||||
<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 })
|
||||
|
||||
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 () => {
|
||||
@@ -53,6 +56,7 @@ describe('marketplace API helpers', () => {
|
||||
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({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vehicleId: 'vehicle_1' }),
|
||||
}))
|
||||
|
||||
@@ -17,8 +17,12 @@ export class MarketplaceApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function marketplaceFetch<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
|
||||
export async function marketplaceFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
...init,
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
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 res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user