add car reservation feature
This commit is contained in:
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
||||
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
@@ -96,22 +97,25 @@ router.get('/search', async (req, res, next) => {
|
||||
|
||||
const typedVehicles = vehicles as Array<(typeof vehicles)[number]>
|
||||
|
||||
const unavailableVehicleIds = startDate && endDate
|
||||
? new Set((await prisma.reservation.findMany({
|
||||
where: {
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
vehicleId: { in: typedVehicles.map((vehicle) => vehicle.id) },
|
||||
startDate: { lt: new Date(endDate) },
|
||||
endDate: { gt: new Date(startDate) },
|
||||
},
|
||||
select: { vehicleId: true },
|
||||
})).map((reservation: { vehicleId: string }) => reservation.vehicleId))
|
||||
: null
|
||||
const availabilityByVehicleId = new Map(
|
||||
await Promise.all(
|
||||
typedVehicles.map(async (vehicle) => {
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
range: startDate && endDate
|
||||
? { startDate: new Date(startDate), endDate: new Date(endDate) }
|
||||
: undefined,
|
||||
})
|
||||
return [vehicle.id, availability] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
res.json({
|
||||
data: typedVehicles.map((vehicle) => ({
|
||||
...vehicle,
|
||||
availability: unavailableVehicleIds ? !unavailableVehicleIds.has(vehicle.id) : null,
|
||||
availability: availabilityByVehicleId.get(vehicle.id)?.available ?? null,
|
||||
availabilityStatus: availabilityByVehicleId.get(vehicle.id)?.status ?? 'AVAILABLE',
|
||||
nextAvailableAt: availabilityByVehicleId.get(vehicle.id)?.nextAvailableAt ?? null,
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -148,15 +152,17 @@ router.post('/reservations', async (req, res, next) => {
|
||||
})
|
||||
if (!vehicle) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
|
||||
|
||||
const conflict = await prisma.reservation.findFirst({
|
||||
where: {
|
||||
vehicleId: body.vehicleId,
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: endDate },
|
||||
endDate: { gt: startDate },
|
||||
},
|
||||
const availability = await getVehicleAvailabilitySummary(body.vehicleId, {
|
||||
range: { startDate, endDate },
|
||||
})
|
||||
if (conflict) return res.status(409).json({ error: 'unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
|
||||
if (!availability.available) {
|
||||
return res.status(409).json({
|
||||
error: 'unavailable',
|
||||
message: 'Vehicle is not available for the selected dates',
|
||||
statusCode: 409,
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { companyId_email: { companyId: vehicle.companyId, email: body.email } },
|
||||
@@ -248,7 +254,18 @@ router.get('/:slug', async (req, res, next) => {
|
||||
},
|
||||
})
|
||||
if (!company) return res.status(404).json({ error: 'not_found', message: 'Company not found', statusCode: 404 })
|
||||
res.json({ data: company })
|
||||
const vehicles = await Promise.all(
|
||||
company.vehicles.map(async (vehicle) => {
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt,
|
||||
}
|
||||
}),
|
||||
)
|
||||
res.json({ data: { ...company, vehicles } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
|
||||
@@ -294,13 +311,10 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||
include: {
|
||||
company: { include: { brand: true } },
|
||||
reservations: {
|
||||
where: { status: { in: ['CONFIRMED', 'ACTIVE'] } },
|
||||
select: { startDate: true, endDate: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
res.json({ data: vehicle })
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
res.json({ data: { ...vehicle, availabilityStatus: availability.status, availability: availability.available, nextAvailableAt: availability.nextAvailableAt } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
|
||||
+35
-22
@@ -8,6 +8,7 @@ import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
import { getMarketplaceHomepageContent } from '../services/platformContentService'
|
||||
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -67,7 +68,18 @@ router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
where: { companyId: company.id, isPublished: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ data: vehicles })
|
||||
const enrichedVehicles = await Promise.all(
|
||||
vehicles.map(async (vehicle) => {
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt,
|
||||
}
|
||||
}),
|
||||
)
|
||||
res.json({ data: enrichedVehicles })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
@@ -79,14 +91,9 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||
include: {
|
||||
reservations: {
|
||||
where: { status: { in: ['CONFIRMED', 'ACTIVE'] } },
|
||||
select: { startDate: true, endDate: true, status: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
res.json({ data: vehicle })
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
res.json({ data: { ...vehicle, availability: availability.available, availabilityStatus: availability.status, nextAvailableAt: availability.nextAvailableAt } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
@@ -143,18 +150,10 @@ router.post('/:slug/availability', async (req, res, next) => {
|
||||
endDate: z.string().datetime(),
|
||||
}).parse(req.body)
|
||||
|
||||
const conflicts = await prisma.reservation.findMany({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
vehicleId,
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: new Date(endDate) },
|
||||
endDate: { gt: new Date(startDate) },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
const availability = await getVehicleAvailabilitySummary(vehicleId, {
|
||||
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
|
||||
})
|
||||
|
||||
res.json({ data: { available: conflicts.length === 0, conflicts } })
|
||||
res.json({ data: { available: availability.available, nextAvailableAt: availability.nextAvailableAt } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
||||
@@ -225,6 +224,23 @@ router.post('/:slug/book', async (req, res, next) => {
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: body.vehicleId, companyId: company.id, isPublished: true },
|
||||
})
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
if (end <= start) {
|
||||
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
|
||||
}
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
range: { startDate: start, endDate: end },
|
||||
})
|
||||
if (!availability.available) {
|
||||
return res.status(409).json({
|
||||
error: 'unavailable',
|
||||
message: 'Vehicle is not available for the selected dates',
|
||||
statusCode: 409,
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { companyId_email: { companyId: company.id, email: body.email } },
|
||||
@@ -251,9 +267,6 @@ router.post('/:slug/book', async (req, res, next) => {
|
||||
nationality: body.nationality ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))
|
||||
const baseAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
|
||||
+130
-11
@@ -112,18 +112,137 @@ router.get('/:id/availability', async (req, res, next) => {
|
||||
const { startDate, endDate } = req.query as Record<string, string>
|
||||
if (!startDate || !endDate) return res.status(400).json({ error: 'missing_params', message: 'startDate and endDate required', statusCode: 400 })
|
||||
|
||||
const conflicts = await prisma.reservation.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
companyId: req.companyId,
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: new Date(endDate) },
|
||||
endDate: { gt: new Date(startDate) },
|
||||
},
|
||||
select: { id: true, startDate: true, endDate: true, status: true },
|
||||
})
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
res.json({ data: { available: conflicts.length === 0, conflicts } })
|
||||
const [reservationConflicts, blockConflicts] = await Promise.all([
|
||||
prisma.reservation.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
companyId: req.companyId,
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: end },
|
||||
endDate: { gt: start },
|
||||
},
|
||||
select: { id: true, startDate: true, endDate: true, status: true },
|
||||
}),
|
||||
prisma.vehicleCalendarBlock.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
startDate: { lt: end },
|
||||
endDate: { gt: start },
|
||||
},
|
||||
select: { id: true, startDate: true, endDate: true, type: true, reason: true },
|
||||
}),
|
||||
])
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
available: reservationConflicts.length === 0 && blockConflicts.length === 0,
|
||||
conflicts: reservationConflicts,
|
||||
blocks: blockConflicts,
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/calendar', async (req, res, next) => {
|
||||
try {
|
||||
const year = parseInt(req.query.year as string)
|
||||
const month = parseInt(req.query.month as string)
|
||||
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
|
||||
return res.status(400).json({ error: 'invalid_params', message: 'year and month (1-12) required', statusCode: 400 })
|
||||
}
|
||||
|
||||
// Validate vehicle belongs to company
|
||||
await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
|
||||
const rangeStart = new Date(year, month - 1, 1)
|
||||
const rangeEnd = new Date(year, month, 1)
|
||||
|
||||
const [reservations, blocks] = await Promise.all([
|
||||
prisma.reservation.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
companyId: req.companyId,
|
||||
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: rangeEnd },
|
||||
endDate: { gt: rangeStart },
|
||||
},
|
||||
select: {
|
||||
id: true, startDate: true, endDate: true, status: true,
|
||||
customer: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
prisma.vehicleCalendarBlock.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
startDate: { lt: rangeEnd },
|
||||
endDate: { gt: rangeStart },
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const events = [
|
||||
...reservations.map((r) => ({
|
||||
id: r.id,
|
||||
type: 'RESERVATION' as const,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
status: r.status,
|
||||
label: r.customer ? `${r.customer.firstName} ${r.customer.lastName}` : 'Reserved',
|
||||
})),
|
||||
...blocks.map((b) => ({
|
||||
id: b.id,
|
||||
type: b.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
status: null,
|
||||
label: b.reason ?? (b.type === 'MAINTENANCE' ? 'Maintenance' : 'Blocked'),
|
||||
})),
|
||||
]
|
||||
|
||||
res.json({ data: events })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/calendar/blocks', async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({
|
||||
startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
|
||||
endDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
|
||||
reason: z.string().optional(),
|
||||
type: z.enum(['MANUAL', 'MAINTENANCE']).default('MANUAL'),
|
||||
}).parse(req.body)
|
||||
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
if (end <= start) return res.status(400).json({ error: 'invalid_range', message: 'endDate must be after startDate', statusCode: 400 })
|
||||
|
||||
await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
|
||||
const block = await prisma.vehicleCalendarBlock.create({
|
||||
data: {
|
||||
vehicleId: req.params.id,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
reason: body.reason,
|
||||
type: body.type,
|
||||
},
|
||||
})
|
||||
res.status(201).json({ data: block })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id/calendar/blocks/:blockId', async (req, res, next) => {
|
||||
try {
|
||||
await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
await prisma.vehicleCalendarBlock.deleteMany({
|
||||
where: { id: req.params.blockId, vehicleId: req.params.id },
|
||||
})
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
const BLOCKING_RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE'] as const
|
||||
|
||||
type AvailabilityStatus = 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
|
||||
type DateRange = {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
}
|
||||
|
||||
type AvailabilitySource = {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
kind: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
|
||||
}
|
||||
|
||||
export interface VehicleAvailabilitySummary {
|
||||
available: boolean
|
||||
status: AvailabilityStatus
|
||||
nextAvailableAt: Date | null
|
||||
blockingReason: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' | 'STATUS' | null
|
||||
}
|
||||
|
||||
function startOfTodayUtc() {
|
||||
const now = new Date()
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||
}
|
||||
|
||||
function overlaps(range: DateRange, source: AvailabilitySource) {
|
||||
return source.startDate < range.endDate && source.endDate > range.startDate
|
||||
}
|
||||
|
||||
function getNextAvailableAt(anchor: Date, sources: AvailabilitySource[]) {
|
||||
const sorted = sources
|
||||
.filter((source) => source.endDate > anchor)
|
||||
.sort((a, b) => {
|
||||
const startDelta = a.startDate.getTime() - b.startDate.getTime()
|
||||
if (startDelta !== 0) return startDelta
|
||||
return a.endDate.getTime() - b.endDate.getTime()
|
||||
})
|
||||
|
||||
let candidate = anchor
|
||||
let advanced = false
|
||||
|
||||
for (const source of sorted) {
|
||||
if (source.endDate <= candidate) continue
|
||||
if (source.startDate > candidate) break
|
||||
candidate = new Date(Math.max(candidate.getTime(), source.endDate.getTime()))
|
||||
advanced = true
|
||||
}
|
||||
|
||||
return advanced ? candidate : null
|
||||
}
|
||||
|
||||
export async function getVehicleAvailabilitySummary(
|
||||
vehicleId: string,
|
||||
{
|
||||
range,
|
||||
includeDraftReservations = true,
|
||||
}: {
|
||||
range?: DateRange
|
||||
includeDraftReservations?: boolean
|
||||
} = {},
|
||||
): Promise<VehicleAvailabilitySummary> {
|
||||
const vehicle = await prisma.vehicle.findUniqueOrThrow({
|
||||
where: { id: vehicleId },
|
||||
select: { status: true },
|
||||
})
|
||||
|
||||
if (vehicle.status === 'OUT_OF_SERVICE') {
|
||||
return {
|
||||
available: false,
|
||||
status: 'UNAVAILABLE',
|
||||
nextAvailableAt: null,
|
||||
blockingReason: 'STATUS',
|
||||
}
|
||||
}
|
||||
|
||||
const anchor = range?.startDate ?? startOfTodayUtc()
|
||||
const reservationStatuses = includeDraftReservations
|
||||
? [...BLOCKING_RESERVATION_STATUSES]
|
||||
: BLOCKING_RESERVATION_STATUSES.filter((status) => status !== 'DRAFT')
|
||||
|
||||
const [reservations, blocks] = await Promise.all([
|
||||
prisma.reservation.findMany({
|
||||
where: {
|
||||
vehicleId,
|
||||
status: { in: reservationStatuses },
|
||||
endDate: { gt: anchor },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
prisma.vehicleCalendarBlock.findMany({
|
||||
where: {
|
||||
vehicleId,
|
||||
endDate: { gt: anchor },
|
||||
},
|
||||
select: { startDate: true, endDate: true, type: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const sources: AvailabilitySource[] = [
|
||||
...reservations.map((reservation) => ({
|
||||
startDate: reservation.startDate,
|
||||
endDate: reservation.endDate,
|
||||
kind: 'RESERVATION' as const,
|
||||
})),
|
||||
...blocks.map((block) => ({
|
||||
startDate: block.startDate,
|
||||
endDate: block.endDate,
|
||||
kind: block.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const,
|
||||
})),
|
||||
]
|
||||
|
||||
const overlappingSources = range ? sources.filter((source) => overlaps(range, source)) : sources.filter((source) => source.startDate <= anchor && source.endDate > anchor)
|
||||
const currentSource = overlappingSources.sort((a, b) => {
|
||||
const priority = { MAINTENANCE: 0, BLOCK: 1, RESERVATION: 2 }
|
||||
return priority[a.kind] - priority[b.kind]
|
||||
})[0]
|
||||
|
||||
if (vehicle.status === 'MAINTENANCE') {
|
||||
return {
|
||||
available: false,
|
||||
status: 'MAINTENANCE',
|
||||
nextAvailableAt: getNextAvailableAt(anchor, sources) ?? null,
|
||||
blockingReason: currentSource?.kind ?? 'STATUS',
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentSource) {
|
||||
return {
|
||||
available: true,
|
||||
status: 'AVAILABLE',
|
||||
nextAvailableAt: null,
|
||||
blockingReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
const nextAvailableAnchor = currentSource.startDate > anchor ? currentSource.startDate : anchor
|
||||
|
||||
return {
|
||||
available: false,
|
||||
status: currentSource.kind === 'RESERVATION' ? 'RESERVED' : currentSource.kind === 'MAINTENANCE' ? 'MAINTENANCE' : 'UNAVAILABLE',
|
||||
nextAvailableAt: getNextAvailableAt(nextAvailableAnchor, sources),
|
||||
blockingReason: currentSource.kind,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user