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() 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(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 }