293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
|
|
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
|
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
|
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
|
|
import * as repo from './marketplace.repo'
|
|
|
|
function normalizeLocation(value?: string | null) {
|
|
const normalized = value?.trim()
|
|
return normalized ? normalized : null
|
|
}
|
|
|
|
function normalizeLocationList(values?: string[] | null) {
|
|
if (!Array.isArray(values)) return []
|
|
return values
|
|
.map((value) => normalizeLocation(value))
|
|
.filter((value): value is string => Boolean(value))
|
|
}
|
|
|
|
function includesLocation(values: string[], target: string) {
|
|
const key = target.toLocaleLowerCase()
|
|
return values.some((value) => value.toLocaleLowerCase() === key)
|
|
}
|
|
|
|
function matchesVehicleLocationRules(
|
|
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
|
|
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
|
|
) {
|
|
const pickupLocation = normalizeLocation(params.pickupLocation)
|
|
const dropoffLocation = normalizeLocation(params.dropoffLocation)
|
|
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
|
|
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
|
|
|
|
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
|
|
return false
|
|
}
|
|
|
|
if (params.dropoffMode === 'different') {
|
|
if (!vehicle.allowDifferentDropoff) return false
|
|
if (dropoffLocation && dropoffLocations.length > 0 && !includesLocation(dropoffLocations, dropoffLocation)) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export async function getPublicOffers() {
|
|
return repo.findPublicOffers()
|
|
}
|
|
|
|
export async function getCities() {
|
|
const rows = await repo.findCitiesFromCompanies()
|
|
const map = new Map<string, string>()
|
|
for (const row of rows) {
|
|
const city = row.brand?.publicCity?.trim()
|
|
if (!city) continue
|
|
const key = city.toLocaleLowerCase()
|
|
if (!map.has(key)) map.set(key, city)
|
|
}
|
|
return Array.from(map.values()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
|
}
|
|
|
|
export async function getListedCompanies(params: {
|
|
city?: string; hasOffer?: string; page?: number; pageSize?: number
|
|
}) {
|
|
const page = params.page ?? 1
|
|
const pageSize = params.pageSize ?? 20
|
|
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
|
|
if (params.city) where.brand = { ...where.brand, publicCity: { contains: params.city, mode: 'insensitive' } }
|
|
if (params.hasOffer === 'true') {
|
|
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
|
|
}
|
|
return repo.findListedCompanies(where, (page - 1) * pageSize, pageSize)
|
|
}
|
|
|
|
export async function searchVehicles(params: {
|
|
city?: string; pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different'
|
|
startDate?: string; endDate?: string; category?: string
|
|
maxPrice?: number; transmission?: string; make?: string; model?: string
|
|
page?: number; pageSize?: number
|
|
}) {
|
|
const page = params.page ?? 1
|
|
const pageSize = params.pageSize ?? 20
|
|
const where: any = {
|
|
isPublished: true,
|
|
status: 'AVAILABLE',
|
|
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
|
}
|
|
if (params.category) where.category = params.category
|
|
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
|
|
if (params.transmission) where.transmission = params.transmission
|
|
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
|
|
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
|
|
if (params.city) {
|
|
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
|
|
}
|
|
|
|
const vehicles = await repo.findPublishedVehicles(where) as any[]
|
|
const locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params))
|
|
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
|
|
|
|
const availability = await Promise.all(
|
|
pagedVehicles.map(async (v: any) => {
|
|
const a = await getVehicleAvailabilitySummary(v.id, {
|
|
companyId: v.companyId,
|
|
range: params.startDate && params.endDate
|
|
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
|
|
: undefined,
|
|
})
|
|
return [v.id, a] as const
|
|
}),
|
|
)
|
|
const availMap = new Map<string, any>(availability)
|
|
|
|
return pagedVehicles.map((v: any) => ({
|
|
...v,
|
|
availability: availMap.get(v.id)?.available ?? null,
|
|
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
|
|
nextAvailableAt: availMap.get(v.id)?.nextAvailableAt ?? null,
|
|
}))
|
|
}
|
|
|
|
export async function createMarketplaceReservation(body: {
|
|
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
|
|
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
|
|
startDate: string; endDate: string
|
|
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
|
|
}) {
|
|
const startDate = new Date(body.startDate)
|
|
const endDate = new Date(body.endDate)
|
|
|
|
if (endDate <= startDate) {
|
|
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
|
}
|
|
|
|
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
|
|
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
|
|
|
const pickupLocation = normalizeLocation(body.pickupLocation)
|
|
const returnLocation = normalizeLocation(body.returnLocation) ?? pickupLocation
|
|
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
|
|
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
|
|
|
|
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
|
|
throw new AppError('Selected pickup location is not available for this vehicle', 400, 'invalid_pickup_location')
|
|
}
|
|
|
|
if (pickupLocation && returnLocation && pickupLocation.toLocaleLowerCase() !== returnLocation.toLocaleLowerCase()) {
|
|
if (!vehicle.allowDifferentDropoff) {
|
|
throw new AppError('This vehicle must be returned to the same pickup location', 400, 'same_dropoff_required')
|
|
}
|
|
if (dropoffLocations.length > 0 && !includesLocation(dropoffLocations, returnLocation)) {
|
|
throw new AppError('Selected return location is not available for this vehicle', 400, 'invalid_return_location')
|
|
}
|
|
}
|
|
|
|
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range: { startDate, endDate } })
|
|
if (!availability.available) {
|
|
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
|
|
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
|
})
|
|
}
|
|
|
|
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
|
|
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))
|
|
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,
|
|
})
|
|
|
|
const lang = (body.language ?? 'fr') as Lang
|
|
const companyName = vehicle.company.brand?.displayName ?? (vehicle.company as any).name
|
|
const emailOpts = {
|
|
firstName: body.firstName, vehicleYear: vehicle.year, vehicleMake: vehicle.make, vehicleModel: vehicle.model,
|
|
companyName, startDate, endDate, totalDays,
|
|
rateDisplay: (vehicle.dailyRate / 100).toFixed(2),
|
|
totalDisplay: (totalAmount / 100).toFixed(2),
|
|
email: body.email, phone: body.phone,
|
|
}
|
|
|
|
sendTransactionalEmail({
|
|
to: body.email,
|
|
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
|
html: marketplaceReservationEmail.html(emailOpts, lang),
|
|
text: marketplaceReservationEmail.text(emailOpts, lang),
|
|
}).catch(() => null)
|
|
|
|
sendNotification({
|
|
type: 'NEW_BOOKING',
|
|
title: 'New Marketplace Reservation Request',
|
|
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}.`,
|
|
companyId: vehicle.companyId,
|
|
channels: ['IN_APP'],
|
|
}).catch(() => null)
|
|
|
|
return { reservationId: reservation.id }
|
|
}
|
|
|
|
export async function getCompanyPage(slug: string) {
|
|
const company = await repo.findCompanyPage(slug)
|
|
if (!company) throw new NotFoundError('Company not found')
|
|
|
|
const vehicles = await Promise.all(
|
|
(company.vehicles as any[]).map(async (v: any) => {
|
|
const a = await getVehicleAvailabilitySummary(v.id, { companyId: v.companyId })
|
|
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
|
}),
|
|
)
|
|
return { ...company, vehicles }
|
|
}
|
|
|
|
export async function getCompanyReviews(slug: string) {
|
|
const company = await repo.findCompanyBySlug(slug)
|
|
return repo.findCompanyReviews(company.id)
|
|
}
|
|
|
|
export async function getCompanyVehicles(slug: string) {
|
|
const company = await repo.findCompanyBySlug(slug)
|
|
return repo.findCompanyVehicles(company.id)
|
|
}
|
|
|
|
export async function getVehicleDetail(slug: string, vehicleId: string) {
|
|
const vehicle = await repo.findVehicleById(slug, vehicleId)
|
|
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId })
|
|
return { ...vehicle, availability: availability.available, availabilityStatus: availability.status, nextAvailableAt: availability.nextAvailableAt }
|
|
}
|
|
|
|
export async function getCompanyOffers(slug: string) {
|
|
const company = await repo.findCompanyBySlug(slug)
|
|
return repo.findCompanyOffers(company.id)
|
|
}
|
|
|
|
export async function getReviewContext(token: string) {
|
|
const reservation = await repo.findReservationByReviewToken(token)
|
|
if (!reservation) throw new NotFoundError('Review link is invalid or has expired')
|
|
if (reservation.review) throw new ConflictError('A review has already been submitted for this reservation')
|
|
|
|
return {
|
|
reservationId: reservation.id,
|
|
companyName: reservation.company.brand?.displayName ?? (reservation.company as any).name,
|
|
companyLogoUrl: reservation.company.brand?.logoUrl ?? null,
|
|
vehicle: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
|
|
vehiclePhoto: reservation.vehicle.photos[0] ?? null,
|
|
}
|
|
}
|
|
|
|
export async function submitReview(token: string, body: {
|
|
overallRating: number; vehicleRating?: number; serviceRating?: number; comment?: string
|
|
}) {
|
|
const reservation = await repo.findReservationForReviewSubmit(token)
|
|
if (!reservation) throw new NotFoundError('Review link is invalid or has expired')
|
|
if (reservation.review) throw new ConflictError('A review has already been submitted for this reservation')
|
|
|
|
const review = await repo.createReview({
|
|
reservationId: reservation.id,
|
|
companyId: reservation.companyId,
|
|
renterId: reservation.renterId,
|
|
...body,
|
|
})
|
|
await repo.invalidateReviewToken(reservation.id)
|
|
return { reviewId: review.id }
|
|
}
|
|
|
|
export async function validateOfferCode(code: string) {
|
|
const offer = await repo.findOfferByCode(code)
|
|
if (!offer) throw new NotFoundError('Promo code not found or expired')
|
|
if (offer.maxRedemptions && offer.redemptionCount >= offer.maxRedemptions) {
|
|
throw new ConflictError('Promo code has reached its maximum redemptions')
|
|
}
|
|
return offer
|
|
}
|