1026 lines
43 KiB
TypeScript
1026 lines
43 KiB
TypeScript
import { Router } from 'express'
|
|
import { z } from 'zod'
|
|
import { prisma } from '../lib/prisma'
|
|
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
|
import { requireTenant } from '../middleware/requireTenant'
|
|
import { requireSubscription } from '../middleware/requireSubscription'
|
|
import { applyAdditionalDriversToReservation } from '../services/additionalDriverService'
|
|
import { applyInsurancesToReservation } from '../services/insuranceService'
|
|
import { applyPricingRules } from '../services/pricingRuleService'
|
|
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
|
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
|
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../lib/emailTranslations'
|
|
import crypto from 'crypto'
|
|
|
|
const router = Router()
|
|
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
|
|
|
const additionalDriverSchema = z.object({
|
|
firstName: z.string().min(1),
|
|
lastName: z.string().min(1),
|
|
email: z.string().email().optional(),
|
|
phone: z.string().optional(),
|
|
driverLicense: z.string().min(1),
|
|
licenseExpiry: z.string().datetime().optional(),
|
|
licenseIssuedAt: z.string().datetime().optional(),
|
|
dateOfBirth: z.string().datetime().optional(),
|
|
nationality: z.string().optional(),
|
|
})
|
|
|
|
const inspectionSchema = z.object({
|
|
mileage: z.number().int().optional(),
|
|
fuelLevel: z.enum(['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']),
|
|
fuelCharge: z.number().int().min(0).optional(),
|
|
generalCondition: z.string().optional(),
|
|
employeeNotes: z.string().optional(),
|
|
customerAgreed: z.boolean().default(false),
|
|
damagePoints: z.array(
|
|
z.object({
|
|
viewType: z.enum(['TOP', 'FRONT', 'REAR', 'LEFT', 'RIGHT']),
|
|
x: z.number(),
|
|
y: z.number(),
|
|
damageType: z.enum(['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']),
|
|
severity: z.enum(['MINOR', 'MODERATE', 'MAJOR']).default('MINOR'),
|
|
description: z.string().optional(),
|
|
isPreExisting: z.boolean().default(false),
|
|
}),
|
|
).default([]),
|
|
})
|
|
|
|
const createSchema = z.object({
|
|
vehicleId: z.string().cuid(),
|
|
customerId: z.string().cuid(),
|
|
startDate: z.string().datetime(),
|
|
endDate: z.string().datetime(),
|
|
pickupLocation: z.string().optional(),
|
|
returnLocation: z.string().optional(),
|
|
offerId: z.string().cuid().optional(),
|
|
promoCodeUsed: z.string().optional(),
|
|
depositAmount: z.number().int().min(0).default(0),
|
|
paymentMode: z.string().max(50).optional(),
|
|
notes: z.string().optional(),
|
|
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
|
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
|
})
|
|
|
|
const updateSchema = z.object({
|
|
startDate: z.string().datetime().optional(),
|
|
endDate: z.string().datetime().optional(),
|
|
pickupLocation: z.string().optional().nullable(),
|
|
returnLocation: z.string().optional().nullable(),
|
|
depositAmount: z.number().int().min(0).optional(),
|
|
notes: z.string().optional().nullable(),
|
|
paymentMode: z.string().max(50).optional().nullable(),
|
|
damageChargeAmount: z.number().int().min(0).optional(),
|
|
damageChargeNote: z.string().optional().nullable(),
|
|
})
|
|
|
|
function parseReservationExtras(extras: unknown) {
|
|
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return {} as Record<string, unknown>
|
|
return { ...(extras as Record<string, unknown>) }
|
|
}
|
|
|
|
function normalizeOptionalString(value: string | null | undefined) {
|
|
const next = typeof value === 'string' ? value.trim() : value
|
|
return next ? next : null
|
|
}
|
|
|
|
function calculateUpdatedInsuranceCharge(
|
|
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL',
|
|
chargeValue: number,
|
|
totalDays: number,
|
|
baseRentalAmount: number,
|
|
) {
|
|
switch (chargeType) {
|
|
case 'PER_DAY':
|
|
return chargeValue * totalDays
|
|
case 'PER_RENTAL':
|
|
return chargeValue
|
|
case 'PERCENTAGE_OF_RENTAL':
|
|
return Math.round(baseRentalAmount * chargeValue / 100)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function calculateUpdatedAdditionalDriverCharge(
|
|
chargeType: 'PER_DAY' | 'FLAT' | 'FREE',
|
|
chargeValue: number,
|
|
totalDays: number,
|
|
) {
|
|
switch (chargeType) {
|
|
case 'PER_DAY':
|
|
return chargeValue * totalDays
|
|
case 'FLAT':
|
|
return chargeValue
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function buildReservationWorkflow(reservation: {
|
|
status: string
|
|
contractNumber: string | null
|
|
invoiceNumber: string | null
|
|
extras: unknown
|
|
}) {
|
|
const extras = parseReservationExtras(reservation.extras)
|
|
const closedAt = typeof extras.reservationClosedAt === 'string' ? extras.reservationClosedAt : null
|
|
const closedBy = typeof extras.reservationClosedBy === 'string' ? extras.reservationClosedBy : null
|
|
const contractGenerated = Boolean(reservation.contractNumber || reservation.invoiceNumber)
|
|
const closed = Boolean(closedAt)
|
|
|
|
return {
|
|
contractGenerated,
|
|
closed,
|
|
closedAt,
|
|
closedBy,
|
|
coreEditable: !closed && !contractGenerated && ['DRAFT', 'CONFIRMED'].includes(reservation.status),
|
|
returnEditable: !closed && reservation.status === 'COMPLETED',
|
|
checkInInspectionEditable: !closed && ['CONFIRMED', 'ACTIVE'].includes(reservation.status),
|
|
checkOutInspectionEditable: !closed && reservation.status === 'COMPLETED',
|
|
}
|
|
}
|
|
|
|
function serializeReservationForDashboard<T extends {
|
|
extras: unknown
|
|
status: string
|
|
contractNumber: string | null
|
|
invoiceNumber: string | null
|
|
}>(reservation: T) {
|
|
const extras = parseReservationExtras(reservation.extras)
|
|
return {
|
|
...reservation,
|
|
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
|
workflow: buildReservationWorkflow(reservation),
|
|
}
|
|
}
|
|
|
|
function formatDocumentNumber(prefix: string, sequence: number) {
|
|
return `${prefix}-${String(sequence).padStart(6, '0')}`
|
|
}
|
|
|
|
async function ensureReservationDocumentNumbers(companyId: string, reservationId: string) {
|
|
return prisma.$transaction(async (tx) => {
|
|
const reservation = await tx.reservation.findFirstOrThrow({
|
|
where: { id: reservationId, companyId },
|
|
select: { id: true, contractNumber: true, invoiceNumber: true },
|
|
})
|
|
|
|
if (reservation.contractNumber && reservation.invoiceNumber) return reservation
|
|
|
|
const settings = await tx.contractSettings.upsert({
|
|
where: { companyId },
|
|
update: {},
|
|
create: { companyId },
|
|
})
|
|
|
|
const data: { contractNumber?: string; invoiceNumber?: string } = {}
|
|
const settingsUpdate: { lastContractSeq?: number; lastInvoiceSeq?: number } = {}
|
|
|
|
if (!reservation.contractNumber) {
|
|
const nextContractSeq = settings.lastContractSeq + 1
|
|
data.contractNumber = formatDocumentNumber(settings.contractPrefix, nextContractSeq)
|
|
settingsUpdate.lastContractSeq = nextContractSeq
|
|
}
|
|
|
|
if (!reservation.invoiceNumber) {
|
|
const nextInvoiceSeq = settings.lastInvoiceSeq + 1
|
|
data.invoiceNumber = formatDocumentNumber(settings.invoicePrefix, nextInvoiceSeq)
|
|
settingsUpdate.lastInvoiceSeq = nextInvoiceSeq
|
|
}
|
|
|
|
if (Object.keys(settingsUpdate).length > 0) {
|
|
await tx.contractSettings.update({
|
|
where: { companyId },
|
|
data: settingsUpdate,
|
|
})
|
|
}
|
|
|
|
return tx.reservation.update({
|
|
where: { id: reservationId },
|
|
data,
|
|
select: { id: true, contractNumber: true, invoiceNumber: true },
|
|
})
|
|
})
|
|
}
|
|
|
|
function buildReservationInvoiceLineItems(reservation: {
|
|
dailyRate: number
|
|
totalDays: number
|
|
discountAmount: number
|
|
depositAmount: number
|
|
pricingRulesApplied: Array<{ name?: string; amount?: number; type?: string }> | null
|
|
insurances: Array<{ id: string; policyName: string; chargeType: string; chargeValue: number; totalCharge: number }>
|
|
additionalDrivers: Array<{ id: string; firstName: string; lastName: string; totalCharge: number }>
|
|
vehicle: { year: number; make: string; model: string }
|
|
}) {
|
|
const baseAmount = reservation.dailyRate * reservation.totalDays
|
|
const lineItems = [
|
|
{
|
|
description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
|
|
qty: reservation.totalDays,
|
|
unitPrice: reservation.dailyRate,
|
|
total: baseAmount,
|
|
category: 'RENTAL',
|
|
},
|
|
...reservation.insurances.map((insurance) => ({
|
|
description: insurance.policyName,
|
|
qty: insurance.chargeType === 'PER_DAY' ? reservation.totalDays : 1,
|
|
unitPrice: insurance.chargeType === 'PER_DAY' ? insurance.chargeValue : insurance.totalCharge,
|
|
total: insurance.totalCharge,
|
|
category: 'INSURANCE',
|
|
})),
|
|
...reservation.additionalDrivers
|
|
.filter((driver) => driver.totalCharge > 0)
|
|
.map((driver) => ({
|
|
description: `Additional driver - ${driver.firstName} ${driver.lastName}`,
|
|
qty: 1,
|
|
unitPrice: driver.totalCharge,
|
|
total: driver.totalCharge,
|
|
category: 'ADDITIONAL_DRIVER',
|
|
})),
|
|
...((reservation.pricingRulesApplied ?? []).map((rule, index) => ({
|
|
description: rule.name?.trim() || `Pricing adjustment ${index + 1}`,
|
|
qty: 1,
|
|
unitPrice: Number(rule.amount ?? 0),
|
|
total: Number(rule.amount ?? 0),
|
|
category: 'PRICING_RULE',
|
|
}))),
|
|
...(reservation.discountAmount > 0 ? [{
|
|
description: 'Discount',
|
|
qty: 1,
|
|
unitPrice: -reservation.discountAmount,
|
|
total: -reservation.discountAmount,
|
|
category: 'DISCOUNT',
|
|
}] : []),
|
|
...(reservation.depositAmount > 0 ? [{
|
|
description: 'Security deposit',
|
|
qty: 1,
|
|
unitPrice: reservation.depositAmount,
|
|
total: reservation.depositAmount,
|
|
category: 'DEPOSIT',
|
|
}] : []),
|
|
]
|
|
|
|
return lineItems
|
|
}
|
|
|
|
async function assertReservationLicenseCompliance(reservationId: string, companyId: string) {
|
|
const reservation = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: reservationId, companyId },
|
|
include: { customer: true, additionalDrivers: true },
|
|
})
|
|
|
|
const customerLicense = validateLicense(reservation.customer.licenseExpiry)
|
|
const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus)
|
|
if (customerDenied || customerLicense.status === 'EXPIRED') {
|
|
throw Object.assign(new Error('Primary driver license is not valid for this reservation'), {
|
|
statusCode: 400,
|
|
code: 'invalid_primary_license',
|
|
})
|
|
}
|
|
|
|
if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') {
|
|
throw Object.assign(new Error('Primary driver license requires manager approval before this reservation can proceed'), {
|
|
statusCode: 400,
|
|
code: 'primary_license_requires_approval',
|
|
})
|
|
}
|
|
|
|
const blockedDriver = reservation.additionalDrivers.find((driver) => {
|
|
if (driver.licenseExpired) return true
|
|
return driver.requiresApproval && !driver.approvedAt
|
|
})
|
|
|
|
if (blockedDriver) {
|
|
throw Object.assign(new Error(`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`), {
|
|
statusCode: 400,
|
|
code: 'additional_driver_requires_approval',
|
|
})
|
|
}
|
|
}
|
|
|
|
router.get('/', async (req, res, next) => {
|
|
try {
|
|
const { status, vehicleId, source, startDate, endDate, page = '1', pageSize = '20' } = req.query as Record<string, string>
|
|
const where: any = { companyId: req.companyId }
|
|
if (status) where.status = status
|
|
if (vehicleId) where.vehicleId = vehicleId
|
|
if (source) where.source = source
|
|
if (startDate) where.startDate = { gte: new Date(startDate) }
|
|
if (endDate) where.endDate = { lte: new Date(endDate) }
|
|
|
|
const [reservations, total] = await Promise.all([
|
|
prisma.reservation.findMany({
|
|
where,
|
|
include: { vehicle: true, customer: true },
|
|
skip: (parseInt(page) - 1) * parseInt(pageSize),
|
|
take: parseInt(pageSize),
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
prisma.reservation.count({ where }),
|
|
])
|
|
|
|
res.json({
|
|
data: reservations.map((reservation) => serializeReservationForDashboard(reservation)),
|
|
total,
|
|
page: parseInt(page),
|
|
pageSize: parseInt(pageSize),
|
|
totalPages: Math.ceil(total / parseInt(pageSize)),
|
|
})
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/', async (req, res, next) => {
|
|
try {
|
|
const body = createSchema.parse(req.body)
|
|
|
|
// Validate vehicle belongs to this company
|
|
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: body.vehicleId, companyId: req.companyId } })
|
|
const customer = await prisma.customer.findFirstOrThrow({ where: { id: body.customerId, companyId: req.companyId } })
|
|
|
|
const start = new Date(body.startDate)
|
|
const end = new Date(body.endDate)
|
|
const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
|
|
|
|
// Check availability
|
|
const conflict = await prisma.reservation.findFirst({
|
|
where: { vehicleId: body.vehicleId, status: { in: ['CONFIRMED', 'ACTIVE'] }, startDate: { lt: end }, endDate: { gt: start } },
|
|
})
|
|
if (conflict) return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
|
|
|
|
let discountAmount = 0
|
|
let offerId: string | null = body.offerId ?? null
|
|
|
|
if (body.promoCodeUsed) {
|
|
const offer = await prisma.offer.findFirst({
|
|
where: { companyId: req.companyId, promoCode: body.promoCodeUsed, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
|
})
|
|
if (offer) {
|
|
offerId = offer.id
|
|
const base = vehicle.dailyRate * totalDays
|
|
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(base * offer.discountValue / 100)
|
|
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
|
|
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
|
|
await prisma.offer.update({ where: { id: offer.id }, data: { redemptionCount: { increment: 1 } } })
|
|
}
|
|
}
|
|
|
|
const baseAmount = vehicle.dailyRate * totalDays
|
|
const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, body.additionalDrivers as any[], vehicle.dailyRate, totalDays)
|
|
const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount
|
|
|
|
const reservation = await prisma.reservation.create({
|
|
data: {
|
|
companyId: req.companyId,
|
|
vehicleId: body.vehicleId,
|
|
customerId: body.customerId,
|
|
startDate: start,
|
|
endDate: end,
|
|
pickupLocation: body.pickupLocation ?? null,
|
|
returnLocation: body.returnLocation ?? null,
|
|
offerId,
|
|
promoCodeUsed: body.promoCodeUsed ?? null,
|
|
source: 'DASHBOARD',
|
|
dailyRate: vehicle.dailyRate,
|
|
discountAmount,
|
|
totalDays,
|
|
totalAmount,
|
|
depositAmount: body.depositAmount,
|
|
notes: body.notes ?? null,
|
|
extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined,
|
|
pricingRulesApplied: applied,
|
|
pricingRulesTotal: pricingTotal,
|
|
},
|
|
include: { vehicle: true, customer: true },
|
|
})
|
|
|
|
if (body.selectedInsurancePolicyIds.length > 0) {
|
|
await applyInsurancesToReservation(reservation.id, req.companyId, body.selectedInsurancePolicyIds, totalDays, baseAmount)
|
|
}
|
|
|
|
if (body.additionalDrivers.length > 0) {
|
|
await applyAdditionalDriversToReservation(reservation.id, req.companyId, body.additionalDrivers, totalDays)
|
|
}
|
|
|
|
// Validate customer license
|
|
await validateAndFlagLicense(body.customerId).catch(() => null)
|
|
|
|
res.status(201).json({ data: reservation })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/:id', async (req, res, next) => {
|
|
try {
|
|
const reservation = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
|
})
|
|
res.json({ data: serializeReservationForDashboard(reservation) })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.patch('/:id', async (req, res, next) => {
|
|
try {
|
|
const body = updateSchema.parse(req.body)
|
|
const reservation = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
include: {
|
|
insurances: true,
|
|
additionalDrivers: true,
|
|
},
|
|
})
|
|
|
|
if (reservation.status === 'CANCELLED' || reservation.status === 'NO_SHOW') {
|
|
return res.status(400).json({ error: 'invalid_status', message: 'This reservation can no longer be edited', statusCode: 400 })
|
|
}
|
|
|
|
const workflow = buildReservationWorkflow(reservation)
|
|
const isReturnEdit = workflow.returnEditable
|
|
const requestedFields = Object.keys(body).filter((key) => body[key as keyof typeof body] !== undefined)
|
|
const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode']
|
|
const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote']
|
|
|
|
if (!workflow.coreEditable && !isReturnEdit) {
|
|
return res.status(400).json({ error: 'reservation_locked', message: 'This reservation is locked for editing', statusCode: 400 })
|
|
}
|
|
|
|
if (workflow.coreEditable) {
|
|
const invalidFields = requestedFields.filter((field) => !bookingFields.includes(field))
|
|
if (invalidFields.length > 0) {
|
|
return res.status(400).json({ error: 'invalid_fields', message: 'Only booking details can be edited at this stage', statusCode: 400 })
|
|
}
|
|
|
|
const nextStartDate = body.startDate ? new Date(body.startDate) : reservation.startDate
|
|
const nextEndDate = body.endDate ? new Date(body.endDate) : reservation.endDate
|
|
const totalDays = Math.ceil((nextEndDate.getTime() - nextStartDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
|
|
if (nextEndDate <= nextStartDate || totalDays <= 0) {
|
|
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
|
|
}
|
|
|
|
if (body.startDate || body.endDate) {
|
|
const conflict = await prisma.reservation.findFirst({
|
|
where: {
|
|
id: { not: reservation.id },
|
|
vehicleId: reservation.vehicleId,
|
|
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
|
|
startDate: { lt: nextEndDate },
|
|
endDate: { gt: nextStartDate },
|
|
},
|
|
})
|
|
|
|
if (conflict) {
|
|
return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
|
|
}
|
|
}
|
|
|
|
const baseAmount = reservation.dailyRate * totalDays
|
|
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
|
req.companyId,
|
|
reservation.customerId,
|
|
reservation.additionalDrivers.map((driver) => ({
|
|
dateOfBirth: driver.dateOfBirth,
|
|
licenseIssuedAt: driver.licenseIssuedAt,
|
|
})),
|
|
reservation.dailyRate,
|
|
totalDays,
|
|
)
|
|
|
|
const insuranceUpdates = reservation.insurances.map((insurance) => ({
|
|
id: insurance.id,
|
|
totalCharge: calculateUpdatedInsuranceCharge(insurance.chargeType, insurance.chargeValue, totalDays, baseAmount),
|
|
}))
|
|
const additionalDriverUpdates = reservation.additionalDrivers.map((driver) => ({
|
|
id: driver.id,
|
|
totalCharge: calculateUpdatedAdditionalDriverCharge(driver.chargeType, driver.chargeValue, totalDays),
|
|
}))
|
|
|
|
const insuranceTotal = insuranceUpdates.reduce((sum, insurance) => sum + insurance.totalCharge, 0)
|
|
const additionalDriverTotal = additionalDriverUpdates.reduce((sum, driver) => sum + driver.totalCharge, 0)
|
|
const depositAmount = body.depositAmount ?? reservation.depositAmount
|
|
const totalAmount = baseAmount - reservation.discountAmount + pricingRulesTotal + insuranceTotal + additionalDriverTotal + depositAmount
|
|
|
|
const extras = parseReservationExtras(reservation.extras)
|
|
if (body.paymentMode !== undefined) {
|
|
const nextPaymentMode = normalizeOptionalString(body.paymentMode)
|
|
if (nextPaymentMode) extras.paymentMode = nextPaymentMode
|
|
else delete extras.paymentMode
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.reservation.update({
|
|
where: { id: reservation.id },
|
|
data: {
|
|
startDate: nextStartDate,
|
|
endDate: nextEndDate,
|
|
totalDays,
|
|
pickupLocation: body.pickupLocation !== undefined ? normalizeOptionalString(body.pickupLocation) : reservation.pickupLocation,
|
|
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
|
|
depositAmount,
|
|
notes: body.notes !== undefined ? normalizeOptionalString(body.notes) : reservation.notes,
|
|
pricingRulesApplied: applied,
|
|
pricingRulesTotal,
|
|
insuranceTotal,
|
|
additionalDriverTotal,
|
|
totalAmount,
|
|
extras: (Object.keys(extras).length > 0 ? extras : {}) as any,
|
|
},
|
|
}),
|
|
...insuranceUpdates.map((insurance) =>
|
|
prisma.reservationInsurance.update({
|
|
where: { id: insurance.id },
|
|
data: { totalCharge: insurance.totalCharge },
|
|
})
|
|
),
|
|
...additionalDriverUpdates.map((driver) =>
|
|
prisma.additionalDriver.update({
|
|
where: { id: driver.id },
|
|
data: { totalCharge: driver.totalCharge },
|
|
})
|
|
),
|
|
])
|
|
} else {
|
|
const invalidFields = requestedFields.filter((field) => !returnFields.includes(field))
|
|
if (invalidFields.length > 0) {
|
|
return res.status(400).json({ error: 'invalid_fields', message: 'Only return details can be edited after vehicle return', statusCode: 400 })
|
|
}
|
|
|
|
await prisma.reservation.update({
|
|
where: { id: reservation.id },
|
|
data: {
|
|
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
|
|
damageChargeAmount: body.damageChargeAmount !== undefined ? body.damageChargeAmount : reservation.damageChargeAmount,
|
|
damageChargeNote: body.damageChargeNote !== undefined ? normalizeOptionalString(body.damageChargeNote) : reservation.damageChargeNote,
|
|
},
|
|
})
|
|
}
|
|
|
|
const updated = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
|
})
|
|
|
|
res.json({ data: serializeReservationForDashboard(updated) })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/:id/contract', async (req, res, next) => {
|
|
try {
|
|
await ensureReservationDocumentNumbers(req.companyId, req.params.id)
|
|
|
|
const reservation = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
include: {
|
|
vehicle: true,
|
|
customer: true,
|
|
insurances: true,
|
|
additionalDrivers: true,
|
|
rentalPayments: { orderBy: { createdAt: 'asc' } },
|
|
inspections: { include: { damagePoints: true }, orderBy: { inspectedAt: 'asc' } },
|
|
company: {
|
|
include: {
|
|
brand: true,
|
|
contractSettings: true,
|
|
accountingSettings: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const contractSettings = reservation.company.contractSettings ?? await prisma.contractSettings.upsert({
|
|
where: { companyId: req.companyId },
|
|
update: {},
|
|
create: { companyId: req.companyId },
|
|
})
|
|
|
|
const paymentMode = reservation.extras && typeof reservation.extras === 'object' && !Array.isArray(reservation.extras)
|
|
? String((reservation.extras as Record<string, unknown>).paymentMode ?? '')
|
|
: ''
|
|
|
|
const invoiceLineItems = buildReservationInvoiceLineItems({
|
|
dailyRate: reservation.dailyRate,
|
|
totalDays: reservation.totalDays,
|
|
discountAmount: reservation.discountAmount,
|
|
depositAmount: reservation.depositAmount,
|
|
pricingRulesApplied: Array.isArray(reservation.pricingRulesApplied) ? reservation.pricingRulesApplied as Array<{ name?: string; amount?: number; type?: string }> : [],
|
|
insurances: reservation.insurances,
|
|
additionalDrivers: reservation.additionalDrivers,
|
|
vehicle: reservation.vehicle,
|
|
})
|
|
|
|
const subtotal = invoiceLineItems.reduce((sum, item) => sum + item.total, 0)
|
|
const taxes = contractSettings.showTax && contractSettings.taxRate
|
|
? [{
|
|
label: contractSettings.taxLabel?.trim() || 'Tax',
|
|
rate: contractSettings.taxRate,
|
|
amount: Math.round(subtotal * contractSettings.taxRate / 100),
|
|
}]
|
|
: []
|
|
const taxTotal = taxes.reduce((sum, tax) => sum + tax.amount, 0)
|
|
const grandTotal = subtotal + taxTotal
|
|
const amountPaid = reservation.rentalPayments
|
|
.filter((payment) => payment.status === 'SUCCEEDED')
|
|
.reduce((sum, payment) => sum + payment.amount, 0)
|
|
|
|
const checkInInspection = reservation.inspections.find((inspection) => inspection.type === 'CHECKIN') ?? null
|
|
const checkOutInspection = reservation.inspections.find((inspection) => inspection.type === 'CHECKOUT') ?? null
|
|
|
|
res.json({
|
|
data: {
|
|
reservationId: reservation.id,
|
|
contractLocale: reservation.company.brand?.defaultLocale ?? 'en',
|
|
contractNumber: reservation.contractNumber,
|
|
invoiceNumber: reservation.invoiceNumber,
|
|
generatedAt: new Date().toISOString(),
|
|
status: reservation.status,
|
|
paymentStatus: reservation.paymentStatus,
|
|
paymentMode: paymentMode || null,
|
|
notes: reservation.notes,
|
|
company: {
|
|
name: reservation.company.brand?.displayName ?? reservation.company.name,
|
|
legalName: contractSettings.legalName || reservation.company.name,
|
|
email: reservation.company.brand?.publicEmail ?? reservation.company.email,
|
|
phone: reservation.company.brand?.publicPhone ?? reservation.company.phone,
|
|
address: reservation.company.brand?.publicAddress ?? reservation.company.address ?? null,
|
|
city: reservation.company.brand?.publicCity ?? null,
|
|
country: reservation.company.brand?.publicCountry ?? null,
|
|
registrationNumber: contractSettings.registrationNumber,
|
|
taxId: contractSettings.taxId,
|
|
logoUrl: reservation.company.brand?.logoUrl ?? null,
|
|
},
|
|
driver: {
|
|
firstName: reservation.customer.firstName,
|
|
lastName: reservation.customer.lastName,
|
|
email: reservation.customer.email,
|
|
phone: reservation.customer.phone,
|
|
dateOfBirth: reservation.customer.dateOfBirth,
|
|
driverLicense: reservation.customer.driverLicense,
|
|
licenseCountry: reservation.customer.licenseCountry,
|
|
licenseCategory: reservation.customer.licenseCategory,
|
|
licenseIssuedAt: reservation.customer.licenseIssuedAt,
|
|
licenseExpiry: reservation.customer.licenseExpiry,
|
|
nationality: reservation.customer.nationality,
|
|
},
|
|
additionalDrivers: reservation.additionalDrivers.map((driver) => ({
|
|
id: driver.id,
|
|
firstName: driver.firstName,
|
|
lastName: driver.lastName,
|
|
email: driver.email,
|
|
phone: driver.phone,
|
|
driverLicense: driver.driverLicense,
|
|
licenseIssuedAt: driver.licenseIssuedAt,
|
|
licenseExpiry: driver.licenseExpiry,
|
|
licenseCountry: driver.nationality,
|
|
dateOfBirth: driver.dateOfBirth,
|
|
totalCharge: driver.totalCharge,
|
|
})),
|
|
vehicle: {
|
|
make: reservation.vehicle.make,
|
|
model: reservation.vehicle.model,
|
|
year: reservation.vehicle.year,
|
|
color: reservation.vehicle.color,
|
|
licensePlate: reservation.vehicle.licensePlate,
|
|
vin: reservation.vehicle.vin,
|
|
category: reservation.vehicle.category,
|
|
},
|
|
rentalPeriod: {
|
|
startDate: reservation.startDate,
|
|
endDate: reservation.endDate,
|
|
totalDays: reservation.totalDays,
|
|
pickupLocation: reservation.pickupLocation,
|
|
returnLocation: reservation.returnLocation,
|
|
},
|
|
insurance: {
|
|
total: reservation.insuranceTotal,
|
|
policies: reservation.insurances.map((insurance) => ({
|
|
id: insurance.id,
|
|
name: insurance.policyName,
|
|
chargeType: insurance.chargeType,
|
|
unitPrice: insurance.chargeValue,
|
|
totalCharge: insurance.totalCharge,
|
|
})),
|
|
},
|
|
inspections: {
|
|
checkIn: checkInInspection,
|
|
checkOut: checkOutInspection,
|
|
},
|
|
terms: {
|
|
terms: contractSettings.terms,
|
|
fuelPolicy: contractSettings.fuelPolicy,
|
|
fuelPolicyType: contractSettings.fuelPolicyType,
|
|
depositPolicy: contractSettings.depositPolicy,
|
|
lateFeePolicy: contractSettings.lateFeePolicy,
|
|
damagePolicy: contractSettings.damagePolicy,
|
|
footerNote: contractSettings.contractFooterNote,
|
|
signatureRequired: contractSettings.signatureRequired,
|
|
},
|
|
invoice: {
|
|
currency: reservation.company.accountingSettings?.currency ?? reservation.company.brand?.defaultCurrency ?? 'MAD',
|
|
lineItems: invoiceLineItems,
|
|
subtotal,
|
|
taxes,
|
|
taxTotal,
|
|
total: grandTotal,
|
|
amountPaid,
|
|
balanceDue: grandTotal - amountPaid,
|
|
payments: reservation.rentalPayments.map((payment) => ({
|
|
id: payment.id,
|
|
amount: payment.amount,
|
|
currency: payment.currency,
|
|
type: payment.type,
|
|
status: payment.status,
|
|
provider: payment.paymentProvider,
|
|
paymentMethod: payment.paymentMethod,
|
|
paidAt: payment.paidAt,
|
|
createdAt: payment.createdAt,
|
|
})),
|
|
},
|
|
},
|
|
})
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/:id/confirm', async (req, res, next) => {
|
|
try {
|
|
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
|
if (reservation.status !== 'DRAFT') return res.status(400).json({ error: 'invalid_status', message: 'Only DRAFT reservations can be confirmed', statusCode: 400 })
|
|
await assertReservationLicenseCompliance(reservation.id, req.companyId)
|
|
|
|
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } })
|
|
|
|
// Notify
|
|
const [customer, companyBrand] = await Promise.all([
|
|
prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }),
|
|
prisma.brandSettings.findUnique({ where: { companyId: req.companyId }, select: { defaultLocale: true } }),
|
|
])
|
|
const lang = ((companyBrand?.defaultLocale ?? 'fr') as Lang)
|
|
await sendNotification({ type: 'BOOKING_CONFIRMED', title: bookingConfirmedNotif.title(lang), body: bookingConfirmedNotif.body(lang), companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'], locale: lang }).catch(() => null)
|
|
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/:id/checkin', async (req, res, next) => {
|
|
try {
|
|
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
|
|
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
|
if (reservation.status !== 'CONFIRMED') return res.status(400).json({ error: 'invalid_status', message: 'Only CONFIRMED reservations can be checked in', statusCode: 400 })
|
|
await assertReservationLicenseCompliance(reservation.id, req.companyId)
|
|
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null } })
|
|
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'RENTED' } })
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/:id/checkout', async (req, res, next) => {
|
|
try {
|
|
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
|
|
const reservation = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
include: { vehicle: true },
|
|
})
|
|
if (reservation.status !== 'ACTIVE') return res.status(400).json({ error: 'invalid_status', message: 'Only ACTIVE reservations can be checked out', statusCode: 400 })
|
|
|
|
const reviewToken = crypto.randomBytes(32).toString('hex')
|
|
const updated = await prisma.reservation.update({
|
|
where: { id: req.params.id },
|
|
data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null, reviewToken },
|
|
})
|
|
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE', mileage: mileage ?? undefined } })
|
|
|
|
// Send "How was your rental?" email with secure one-time review link
|
|
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
|
|
const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true, defaultLocale: true } } } })
|
|
const lang = ((company?.brand?.defaultLocale ?? 'fr') as Lang)
|
|
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
|
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
|
const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
|
|
const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}`
|
|
const reviewOpts = { firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }
|
|
|
|
sendTransactionalEmail({
|
|
to: customer.email,
|
|
subject: reviewRequestEmail.subject(vehicleLabel, lang),
|
|
html: reviewRequestEmail.html(reviewOpts, lang),
|
|
text: reviewRequestEmail.text(reviewOpts, lang),
|
|
}).catch(() => null)
|
|
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/:id/close', async (req, res, next) => {
|
|
try {
|
|
const reservation = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
})
|
|
const workflow = buildReservationWorkflow(reservation)
|
|
|
|
if (reservation.status !== 'COMPLETED') {
|
|
return res.status(400).json({ error: 'invalid_status', message: 'Only completed reservations can be closed', statusCode: 400 })
|
|
}
|
|
|
|
if (workflow.closed) {
|
|
return res.status(400).json({ error: 'already_closed', message: 'This reservation is already closed', statusCode: 400 })
|
|
}
|
|
|
|
const extras = parseReservationExtras(reservation.extras)
|
|
extras.reservationClosedAt = new Date().toISOString()
|
|
extras.reservationClosedBy = `${req.employee.firstName} ${req.employee.lastName}`
|
|
|
|
const updated = await prisma.reservation.update({
|
|
where: { id: reservation.id },
|
|
data: { extras: extras as any },
|
|
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
|
})
|
|
|
|
res.json({ data: serializeReservationForDashboard(updated) })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/:id/cancel', async (req, res, next) => {
|
|
try {
|
|
const { reason } = z.object({ reason: z.string().optional() }).parse(req.body)
|
|
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
|
if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) {
|
|
return res.status(400).json({ error: 'invalid_status', message: 'Reservation cannot be cancelled', statusCode: 400 })
|
|
}
|
|
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' } })
|
|
if (['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
|
|
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE' } })
|
|
}
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/:id/billing', async (req, res, next) => {
|
|
try {
|
|
const reservation = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
include: { vehicle: true, insurances: true, additionalDrivers: true },
|
|
})
|
|
|
|
const baseAmount = reservation.dailyRate * reservation.totalDays
|
|
const lineItems = [
|
|
{ description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model} — ${reservation.totalDays} day(s)`, qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL' },
|
|
...reservation.insurances.map((ins: (typeof reservation.insurances)[number]) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })),
|
|
...reservation.additionalDrivers.filter((d: (typeof reservation.additionalDrivers)[number]) => d.totalCharge > 0).map((d: (typeof reservation.additionalDrivers)[number]) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })),
|
|
...(reservation.depositAmount > 0 ? [{ description: 'Security Deposit (refundable)', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT' }] : []),
|
|
]
|
|
|
|
const grandTotal = lineItems.reduce((s, i) => s + i.total, 0) - reservation.discountAmount
|
|
|
|
res.json({ data: { lineItems, discountAmount: reservation.discountAmount, pricingRulesApplied: reservation.pricingRulesApplied, pricingRulesTotal: reservation.pricingRulesTotal, grandTotal } })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/:id/inspections', async (req, res, next) => {
|
|
try {
|
|
const inspections = await prisma.damageInspection.findMany({
|
|
where: { reservationId: req.params.id, companyId: req.companyId },
|
|
include: { damagePoints: true },
|
|
orderBy: { inspectedAt: 'asc' },
|
|
})
|
|
res.json({ data: inspections })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.put('/:id/inspections/:type', async (req, res, next) => {
|
|
try {
|
|
const type = z.enum(['CHECKIN', 'CHECKOUT']).parse(req.params.type.toUpperCase())
|
|
const body = inspectionSchema.parse(req.body)
|
|
const reservation = await prisma.reservation.findFirstOrThrow({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
include: { vehicle: true, customer: true },
|
|
})
|
|
const workflow = buildReservationWorkflow(reservation)
|
|
|
|
if (workflow.closed) {
|
|
return res.status(400).json({ error: 'reservation_closed', message: 'This reservation has already been closed', statusCode: 400 })
|
|
}
|
|
|
|
if (type === 'CHECKIN' && !workflow.checkInInspectionEditable) {
|
|
return res.status(400).json({ error: 'invalid_status', message: 'Check-in inspection is not editable at this stage', statusCode: 400 })
|
|
}
|
|
|
|
if (type === 'CHECKOUT' && !workflow.checkOutInspectionEditable) {
|
|
return res.status(400).json({ error: 'invalid_status', message: 'Check-out inspection is only editable after the vehicle is returned', statusCode: 400 })
|
|
}
|
|
|
|
const inspection = await prisma.damageInspection.upsert({
|
|
where: { reservationId_type: { reservationId: reservation.id, type } },
|
|
update: {
|
|
mileage: body.mileage ?? null,
|
|
fuelLevel: body.fuelLevel,
|
|
fuelCharge: body.fuelCharge ?? null,
|
|
generalCondition: body.generalCondition ?? null,
|
|
employeeNotes: body.employeeNotes ?? null,
|
|
customerAgreed: body.customerAgreed,
|
|
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
|
inspectedAt: new Date(),
|
|
damagePoints: {
|
|
deleteMany: {},
|
|
create: body.damagePoints,
|
|
},
|
|
},
|
|
create: {
|
|
reservationId: reservation.id,
|
|
companyId: req.companyId,
|
|
type,
|
|
mileage: body.mileage ?? null,
|
|
fuelLevel: body.fuelLevel,
|
|
fuelCharge: body.fuelCharge ?? null,
|
|
generalCondition: body.generalCondition ?? null,
|
|
employeeNotes: body.employeeNotes ?? null,
|
|
customerAgreed: body.customerAgreed,
|
|
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
|
damagePoints: {
|
|
create: body.damagePoints,
|
|
},
|
|
},
|
|
include: { damagePoints: true },
|
|
})
|
|
|
|
await prisma.damageReport.upsert({
|
|
where: { reservationId_type: { reservationId: reservation.id, type } },
|
|
update: {
|
|
damages: body.damagePoints.map((point) => ({
|
|
viewType: point.viewType,
|
|
x: point.x,
|
|
y: point.y,
|
|
damageType: point.damageType,
|
|
severity: point.severity,
|
|
note: point.description ?? '',
|
|
isPreExisting: point.isPreExisting,
|
|
})),
|
|
fuelLevel: body.fuelLevel,
|
|
mileage: body.mileage ?? null,
|
|
inspectedAt: new Date(),
|
|
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
|
customerSignedAt: body.customerAgreed ? new Date() : null,
|
|
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
|
},
|
|
create: {
|
|
reservationId: reservation.id,
|
|
companyId: req.companyId,
|
|
type,
|
|
damages: body.damagePoints.map((point) => ({
|
|
viewType: point.viewType,
|
|
x: point.x,
|
|
y: point.y,
|
|
damageType: point.damageType,
|
|
severity: point.severity,
|
|
note: point.description ?? '',
|
|
isPreExisting: point.isPreExisting,
|
|
})),
|
|
photos: [],
|
|
fuelLevel: body.fuelLevel,
|
|
mileage: body.mileage ?? null,
|
|
inspectedAt: new Date(),
|
|
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
|
customerSignedAt: body.customerAgreed ? new Date() : null,
|
|
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
|
},
|
|
})
|
|
|
|
await prisma.reservation.update({
|
|
where: { id: reservation.id },
|
|
data: type === 'CHECKIN'
|
|
? {
|
|
checkInMileage: body.mileage ?? null,
|
|
checkInFuelLevel: body.fuelLevel,
|
|
}
|
|
: {
|
|
checkOutMileage: body.mileage ?? null,
|
|
checkOutFuelLevel: body.fuelLevel,
|
|
},
|
|
})
|
|
|
|
res.json({ data: inspection })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => {
|
|
try {
|
|
const { approved, note } = z.object({ approved: z.boolean(), note: z.string().optional() }).parse(req.body)
|
|
const driver = await prisma.additionalDriver.findFirstOrThrow({
|
|
where: { id: req.params.driverId, reservationId: req.params.id, companyId: req.companyId },
|
|
})
|
|
|
|
const updated = await prisma.additionalDriver.update({
|
|
where: { id: driver.id },
|
|
data: {
|
|
approvedAt: approved ? new Date() : null,
|
|
approvedBy: approved ? `${req.employee.firstName} ${req.employee.lastName}` : null,
|
|
approvalNote: note ?? driver.approvalNote,
|
|
},
|
|
})
|
|
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
export default router
|