Files
carmanagement/apps/api/src/modules/marketplace/marketplace.service.ts
T
2026-06-10 00:40:19 -04:00

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
}