update reservation
This commit is contained in:
@@ -2,6 +2,7 @@ import { prisma } from '../../lib/prisma'
|
||||
|
||||
const companyListInclude = {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
|
||||
contractSettings: { select: { legalName: true } },
|
||||
subscription: { select: { plan: true, status: true } },
|
||||
_count: { select: { employees: true, vehicles: true } },
|
||||
} as const
|
||||
@@ -125,9 +126,27 @@ export function getCompanyUpdateSnapshot(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function applyCompanyUpdate(id: string, body: any, current: { name: string; slug: string; brand?: { paymentMethodsEnabled?: any[] | null } | null }) {
|
||||
export async function applyCompanyUpdate(
|
||||
id: string,
|
||||
body: any,
|
||||
current: {
|
||||
name: string
|
||||
slug: string
|
||||
address?: unknown
|
||||
brand?: { paymentMethodsEnabled?: any[] | null } | null
|
||||
},
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
if (body.company) await tx.company.update({ where: { id }, data: body.company })
|
||||
if (body.company) {
|
||||
const companyData = { ...body.company }
|
||||
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
|
||||
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
|
||||
? current.address as Record<string, unknown>
|
||||
: {}
|
||||
companyData.address = { ...baseAddress, ...companyData.address }
|
||||
}
|
||||
await tx.company.update({ where: { id }, data: companyData })
|
||||
}
|
||||
|
||||
if (body.subscription) {
|
||||
const sub = body.subscription
|
||||
|
||||
@@ -112,6 +112,30 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
email: z.string().email().optional(), phone: nullableString,
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
address: z.object({
|
||||
streetAddress: nullableString,
|
||||
city: nullableString,
|
||||
country: nullableString,
|
||||
zipCode: nullableString,
|
||||
legalName: nullableString,
|
||||
legalForm: nullableString,
|
||||
companyEmail: nullableEmail,
|
||||
managerName: nullableString,
|
||||
iceNumber: nullableString,
|
||||
operatingLicenseNumber: nullableString,
|
||||
operatingLicenseIssuedAt: nullableString,
|
||||
operatingLicenseIssuedBy: nullableString,
|
||||
fax: nullableString,
|
||||
yearsActive: nullableString,
|
||||
representativeName: nullableString,
|
||||
representativeTitle: nullableString,
|
||||
responsibleName: nullableString,
|
||||
responsibleRole: nullableString,
|
||||
responsibleIdentityNumber: nullableString,
|
||||
responsibleQualification: nullableString,
|
||||
responsiblePhone: nullableString,
|
||||
responsibleEmail: nullableEmail,
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
subscription: z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
|
||||
@@ -49,13 +49,59 @@ export async function findVehicleForMarketplace(vehicleId: string, companySlug:
|
||||
}
|
||||
|
||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
email: string; firstName: string; lastName: string; phone?: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone?: string
|
||||
dateOfBirth?: string
|
||||
nationality?: string
|
||||
identityDocumentNumber?: string
|
||||
fullAddress?: string
|
||||
driverLicense?: string
|
||||
licenseExpiry?: string
|
||||
licenseIssuedAt?: string
|
||||
licenseCountry?: string
|
||||
licenseCategory?: string
|
||||
internationalLicenseNumber?: string
|
||||
}) {
|
||||
return prisma.customer.upsert({
|
||||
const existing = await prisma.customer.findUnique({
|
||||
where: { companyId_email: { companyId, email: data.email } },
|
||||
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone },
|
||||
update: { firstName: data.firstName, lastName: data.lastName, ...(data.phone ? { phone: data.phone } : {}) },
|
||||
select: { id: true, address: true },
|
||||
})
|
||||
|
||||
// Only patch the address JSON with fields that were actually provided
|
||||
const prevAddress = (existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address))
|
||||
? existing.address as Record<string, unknown>
|
||||
: {}
|
||||
const hasAddressPatch = data.fullAddress || data.identityDocumentNumber || data.internationalLicenseNumber !== undefined
|
||||
const address = hasAddressPatch
|
||||
? {
|
||||
...prevAddress,
|
||||
...(data.fullAddress ? { fullAddress: data.fullAddress } : {}),
|
||||
...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}),
|
||||
...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
...(data.phone ? { phone: data.phone } : {}),
|
||||
...(data.driverLicense ? { driverLicense: data.driverLicense, licenseNumber: data.driverLicense } : {}),
|
||||
...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}),
|
||||
...(data.nationality ? { nationality: data.nationality } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}),
|
||||
...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}),
|
||||
...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}),
|
||||
...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}),
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
return prisma.customer.create({ data: { companyId, ...payload } })
|
||||
}
|
||||
return prisma.customer.update({ where: { id: existing.id }, data: payload })
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(data: {
|
||||
|
||||
@@ -30,7 +30,19 @@ export const marketplaceReservationSchema = z.object({
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
phone: z.string().max(30).optional(),
|
||||
// Contact info — collected at reservation time
|
||||
phone: z.string().min(1).max(30).optional(),
|
||||
// Identity & license — optional at reservation, collected at pickup
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: z.string().min(1).max(100).optional(),
|
||||
licenseCategory: z.string().min(1).max(20).optional(),
|
||||
internationalLicenseNumber: z.string().trim().max(100).optional(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
|
||||
@@ -121,7 +121,11 @@ export async function searchVehicles(params: {
|
||||
|
||||
export async function createMarketplaceReservation(body: {
|
||||
vehicleId: string; companySlug: string; firstName: string; lastName: string
|
||||
email: string; phone?: string; startDate: string; endDate: string
|
||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
||||
identityDocumentNumber?: string; fullAddress?: string
|
||||
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
|
||||
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
|
||||
startDate: string; endDate: string
|
||||
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
|
||||
}) {
|
||||
const startDate = new Date(body.startDate)
|
||||
@@ -160,7 +164,20 @@ export async function createMarketplaceReservation(body: {
|
||||
}
|
||||
|
||||
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
|
||||
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
phone: body.phone,
|
||||
dateOfBirth: body.dateOfBirth,
|
||||
nationality: body.nationality,
|
||||
identityDocumentNumber: body.identityDocumentNumber,
|
||||
fullAddress: body.fullAddress,
|
||||
driverLicense: body.driverLicense,
|
||||
licenseExpiry: body.licenseExpiry,
|
||||
licenseIssuedAt: body.licenseIssuedAt,
|
||||
licenseCountry: body.licenseCountry,
|
||||
licenseCategory: body.licenseCategory,
|
||||
internationalLicenseNumber: body.internationalLicenseNumber,
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
|
||||
@@ -121,6 +121,16 @@ describe('createMarketplaceReservation', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1', companySlug: SLUG,
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
phone: '+212600000000',
|
||||
dateOfBirth: '1990-01-01T00:00:00.000Z',
|
||||
nationality: 'Moroccan',
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
licenseExpiry: '2030-01-01T00:00:00.000Z',
|
||||
licenseIssuedAt: '2015-01-01T00:00:00.000Z',
|
||||
licenseCountry: 'Morocco',
|
||||
licenseCategory: 'B',
|
||||
startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z',
|
||||
}
|
||||
|
||||
@@ -132,6 +142,11 @@ describe('createMarketplaceReservation', () => {
|
||||
|
||||
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
}))
|
||||
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
|
||||
@@ -8,6 +8,17 @@ import { coerceNotificationLocale } from '../../services/notificationLocalizatio
|
||||
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
function buildPickupAddress(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
|
||||
|
||||
const address = value as Record<string, unknown>
|
||||
return [
|
||||
typeof address.streetAddress === 'string' ? address.streetAddress.trim() : '',
|
||||
typeof address.city === 'string' ? address.city.trim() : '',
|
||||
typeof address.country === 'string' ? address.country.trim() : '',
|
||||
].filter(Boolean).join(', ')
|
||||
}
|
||||
|
||||
async function assertLicenseCompliance(reservationId: string, companyId: string) {
|
||||
const reservation = await repo.findForLicenseCheck(reservationId, companyId)
|
||||
|
||||
@@ -46,6 +57,7 @@ export async function confirmReservation(id: string, companyId: string) {
|
||||
repo.findVehicle(reservation.vehicleId, companyId),
|
||||
])
|
||||
const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale)
|
||||
const pickupLocation = reservation.pickupLocation?.trim() || buildPickupAddress(company?.address)
|
||||
await sendNotification({
|
||||
type: 'BOOKING_CONFIRMED',
|
||||
companyId,
|
||||
@@ -57,8 +69,29 @@ export async function confirmReservation(id: string, companyId: string) {
|
||||
templateVariables: {
|
||||
firstName: customer.firstName,
|
||||
companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo',
|
||||
startDate: reservation.startDate,
|
||||
endDate: reservation.endDate,
|
||||
startDate: {
|
||||
type: 'date',
|
||||
value: reservation.startDate,
|
||||
options: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
},
|
||||
endDate: {
|
||||
type: 'date',
|
||||
value: reservation.endDate,
|
||||
options: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
},
|
||||
pickupLocation,
|
||||
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
|
||||
},
|
||||
}).catch(() => null)
|
||||
|
||||
@@ -72,6 +72,7 @@ import * as pricingService from './reservation.pricing.service'
|
||||
import * as insuranceService from './reservation.insurance.service'
|
||||
import * as additionalDriverService from './reservation.additional-driver.service'
|
||||
import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { sendNotification } from '../../services/notificationService'
|
||||
import { buildReservationWorkflow } from './reservation.presenter'
|
||||
import { createReservation } from './reservation.service'
|
||||
import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
|
||||
@@ -158,7 +159,7 @@ describe('createReservation', () => {
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('confirmReservation', () => {
|
||||
it('confirms a DRAFT reservation', async () => {
|
||||
const res = makeReservation({ status: 'DRAFT' })
|
||||
const res = makeReservation({ status: 'DRAFT', pickupLocation: 'Casablanca Airport Terminal 1' })
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any)
|
||||
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
|
||||
...res,
|
||||
@@ -174,6 +175,20 @@ describe('confirmReservation', () => {
|
||||
const result = await confirmReservation(RES_ID, COMPANY)
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' })
|
||||
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||
templateKey: 'booking.confirmed',
|
||||
templateVariables: expect.objectContaining({
|
||||
pickupLocation: 'Casablanca Airport Terminal 1',
|
||||
startDate: expect.objectContaining({
|
||||
type: 'date',
|
||||
value: res.startDate,
|
||||
}),
|
||||
endDate: expect.objectContaining({
|
||||
type: 'date',
|
||||
value: res.endDate,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
expect((result as any).status).toBe('CONFIRMED')
|
||||
})
|
||||
|
||||
|
||||
@@ -41,19 +41,61 @@ export async function findOfferByPromoCode(companyId: string, code: string) {
|
||||
}
|
||||
|
||||
export async function upsertCustomer(companyId: string, data: {
|
||||
email: string; firstName: string; lastName: string; phone?: string | null
|
||||
driverLicense?: string | null; dateOfBirth?: string | null; licenseExpiry?: string | null
|
||||
licenseIssuedAt?: string | null; nationality?: string | null
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone: string
|
||||
driverLicense: string
|
||||
dateOfBirth: string
|
||||
licenseExpiry: string
|
||||
licenseIssuedAt: string
|
||||
nationality: string
|
||||
identityDocumentNumber: string
|
||||
fullAddress: string
|
||||
licenseCountry: string
|
||||
licenseNumber?: string | null
|
||||
licenseCategory: string
|
||||
internationalLicenseNumber?: string | null
|
||||
}) {
|
||||
const parsed = {
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
|
||||
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null,
|
||||
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
|
||||
}
|
||||
return prisma.customer.upsert({
|
||||
const existing = await prisma.customer.findUnique({
|
||||
where: { companyId_email: { companyId, email: data.email } },
|
||||
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
|
||||
update: { firstName: data.firstName, lastName: data.lastName, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
|
||||
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 payload = {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
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,
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
return prisma.customer.create({
|
||||
data: { companyId, ...payload },
|
||||
})
|
||||
}
|
||||
|
||||
return prisma.customer.update({
|
||||
where: { id: existing.id },
|
||||
data: payload,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,18 @@ export const bookSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
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),
|
||||
licenseNumber: z.string().min(1).max(50).optional(),
|
||||
licenseCategory: z.string().min(1).max(20),
|
||||
internationalLicenseNumber: z.string().trim().max(100).optional(),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
|
||||
@@ -93,8 +93,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
|
||||
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
|
||||
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
|
||||
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
||||
}) {
|
||||
@@ -113,9 +114,21 @@ export async function createBooking(slug: string, body: {
|
||||
}
|
||||
|
||||
const customer = await repo.upsertCustomer(company.id, {
|
||||
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
|
||||
driverLicense: body.driverLicense, dateOfBirth: body.dateOfBirth,
|
||||
licenseExpiry: body.licenseExpiry, licenseIssuedAt: body.licenseIssuedAt, nationality: body.nationality,
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
phone: body.phone,
|
||||
driverLicense: body.driverLicense,
|
||||
dateOfBirth: body.dateOfBirth,
|
||||
licenseExpiry: body.licenseExpiry,
|
||||
licenseIssuedAt: body.licenseIssuedAt,
|
||||
nationality: body.nationality,
|
||||
identityDocumentNumber: body.identityDocumentNumber,
|
||||
fullAddress: body.fullAddress,
|
||||
licenseCountry: body.licenseCountry,
|
||||
licenseNumber: body.licenseNumber,
|
||||
licenseCategory: body.licenseCategory,
|
||||
internationalLicenseNumber: body.internationalLicenseNumber,
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86_400_000))
|
||||
|
||||
Reference in New Issue
Block a user