refactor: rename marketplace to storefront across the entire monorepo
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Comprehensive rename of all marketplace references to storefront: - API module: apps/api/src/modules/marketplace/ → storefront/ - Components: MarketplaceHeader → StorefrontHeader, MarketplaceShell → StorefrontShell, MarketplaceFooter → StorefrontFooter - Types: marketplace-homepage.ts → storefront-homepage.ts - Test files: employee-marketplace-* → employee-storefront-* - All source code identifiers, imports, route paths, and strings - Documentation (docs/), CI config (.gitlab-ci.yml), scripts - Dashboard, admin, storefront workspace references - Prisma field names preserved (isListedOnMarketplace, marketplaceRating, marketplaceFunnelEvent) as they map to database schema Validation: - API type-check: 0 errors - Storefront type-check: 0 errors - Dashboard type-check: 0 errors - Full monorepo type-check: only pre-existing admin TS18046
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentVehicleWithAvailability } from './storefront.presenter'
|
||||
|
||||
describe('storefront.presenter', () => {
|
||||
it('adds public availability fields without mutating the original vehicle shape', () => {
|
||||
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
|
||||
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
|
||||
|
||||
expect(presentVehicleWithAvailability(vehicle, {
|
||||
available: false,
|
||||
status: 'RESERVED',
|
||||
nextAvailableAt,
|
||||
})).toEqual({
|
||||
id: 'vehicle_1',
|
||||
make: 'Dacia',
|
||||
model: 'Logan',
|
||||
dailyRate: 25000,
|
||||
availability: false,
|
||||
availabilityStatus: 'RESERVED',
|
||||
nextAvailableAt,
|
||||
})
|
||||
|
||||
expect(vehicle).toEqual({ id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export function presentVehicleWithAvailability<T extends { id: string }>(
|
||||
vehicle: T,
|
||||
availability: { available: boolean; status: string; nextAvailableAt: Date | null },
|
||||
) {
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
offer: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
company: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn() },
|
||||
vehicle: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn() },
|
||||
reservation: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
|
||||
customer: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||
review: { findMany: vi.fn(), create: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
import * as repo from './storefront.repo'
|
||||
|
||||
describe('storefront.repo public query and write boundaries', () => {
|
||||
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
|
||||
|
||||
await repo.findPublicOffers()
|
||||
|
||||
expect(prismaMock.offer.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: {
|
||||
isPublic: true,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date('2026-06-09T10:00:00.000Z') },
|
||||
validUntil: { gte: new Date('2026-06-09T10:00:00.000Z') },
|
||||
},
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
take: 50,
|
||||
}))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('lists storefront cities only from active listed companies with a public city', async () => {
|
||||
await repo.findCitiesFromCompanies()
|
||||
|
||||
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
||||
},
|
||||
select: { brand: { select: { publicCity: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('requires published vehicles and an active company when loading storefront vehicle details', async () => {
|
||||
await repo.findVehicleForStorefront('vehicle_1', 'atlas')
|
||||
|
||||
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'vehicle_1',
|
||||
isPublished: true,
|
||||
company: { slug: 'atlas', status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
},
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('patches storefront customer address fields without deleting existing address metadata', async () => {
|
||||
prismaMock.customer.findUnique.mockResolvedValueOnce({
|
||||
id: 'customer_1',
|
||||
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
|
||||
})
|
||||
|
||||
await repo.upsertMarketplaceCustomer('company_1', {
|
||||
email: 'renter@example.test',
|
||||
firstName: 'Nora',
|
||||
lastName: 'Renter',
|
||||
identityDocumentNumber: 'ID-9',
|
||||
internationalLicenseNumber: undefined,
|
||||
})
|
||||
|
||||
expect(prismaMock.customer.update).toHaveBeenCalledWith({
|
||||
where: { id: 'customer_1' },
|
||||
data: expect.objectContaining({
|
||||
firstName: 'Nora',
|
||||
address: {
|
||||
fullAddress: 'Old address',
|
||||
loyaltyNote: 'keep',
|
||||
internationalLicenseNumber: 'INT-1',
|
||||
identityDocumentNumber: 'ID-9',
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('creates storefront reservations as draft storefront-sourced records', async () => {
|
||||
const startDate = new Date('2026-07-01T10:00:00.000Z')
|
||||
const endDate = new Date('2026-07-05T10:00:00.000Z')
|
||||
|
||||
await repo.createStorefrontReservation({
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
startDate,
|
||||
endDate,
|
||||
dailyRate: 500,
|
||||
totalDays: 4,
|
||||
totalAmount: 2000,
|
||||
pickupLocation: 'Airport',
|
||||
returnLocation: null,
|
||||
})
|
||||
|
||||
expect(prismaMock.reservation.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
source: 'MARKETPLACE',
|
||||
status: 'DRAFT',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('submits public reviews as published records and prevents null renter ids from becoming explicit nulls', async () => {
|
||||
await repo.createReview({
|
||||
reservationId: 'reservation_1',
|
||||
companyId: 'company_1',
|
||||
renterId: null,
|
||||
overallRating: 5,
|
||||
vehicleRating: 4,
|
||||
serviceRating: 5,
|
||||
comment: 'Clean car',
|
||||
})
|
||||
|
||||
expect(prismaMock.review.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
reservationId: 'reservation_1',
|
||||
companyId: 'company_1',
|
||||
renterId: undefined,
|
||||
overallRating: 5,
|
||||
vehicleRating: 4,
|
||||
serviceRating: 5,
|
||||
comment: 'Clean car',
|
||||
isPublished: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export async function findPublicOffers() {
|
||||
return prisma.offer.findMany({
|
||||
where: { isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
include: { company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, primaryColor: true } } } } },
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
take: 50,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCitiesFromCompanies() {
|
||||
return prisma.company.findMany({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
||||
},
|
||||
select: { brand: { select: { publicCity: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findListedCompanies(where: any, skip: number, take: number) {
|
||||
return prisma.company.findMany({
|
||||
where,
|
||||
include: {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findPublishedVehicles(where: any) {
|
||||
return prisma.vehicle.findMany({
|
||||
where,
|
||||
include: {
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
|
||||
},
|
||||
orderBy: { dailyRate: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForStorefront(vehicleId: string, companySlug: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForStorefrontById(vehicleId: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: {
|
||||
id: vehicleId,
|
||||
isPublished: true,
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
||||
},
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone?: string
|
||||
dateOfBirth?: string
|
||||
nationality?: string
|
||||
identityDocumentNumber?: string
|
||||
fullAddress?: string
|
||||
driverLicense?: string
|
||||
licenseExpiry?: string
|
||||
licenseIssuedAt?: string
|
||||
licenseCountry?: string
|
||||
licenseCategory?: string
|
||||
internationalLicenseNumber?: string
|
||||
}) {
|
||||
const existing = await prisma.customer.findUnique({
|
||||
where: { companyId_email: { companyId, email: data.email } },
|
||||
select: { id: true, address: true },
|
||||
})
|
||||
|
||||
// Only patch the address JSON with fields that were actually provided
|
||||
const prevAddress = (existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address))
|
||||
? existing.address as Record<string, unknown>
|
||||
: {}
|
||||
const hasAddressPatch = data.fullAddress || data.identityDocumentNumber || data.internationalLicenseNumber !== undefined
|
||||
const address = hasAddressPatch
|
||||
? {
|
||||
...prevAddress,
|
||||
...(data.fullAddress ? { fullAddress: data.fullAddress } : {}),
|
||||
...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}),
|
||||
...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
...(data.phone ? { phone: data.phone } : {}),
|
||||
...(data.driverLicense ? { driverLicense: data.driverLicense, licenseNumber: data.driverLicense } : {}),
|
||||
...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}),
|
||||
...(data.nationality ? { nationality: data.nationality } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}),
|
||||
...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}),
|
||||
...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}),
|
||||
...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}),
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
return prisma.customer.create({ data: { companyId, ...payload } })
|
||||
}
|
||||
return prisma.customer.update({ where: { id: existing.id }, data: payload })
|
||||
}
|
||||
|
||||
export async function createStorefrontReservation(data: {
|
||||
companyId: string; vehicleId: string; customerId: string
|
||||
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
||||
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
|
||||
}) {
|
||||
return prisma.reservation.create({
|
||||
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createStorefrontFunnelEvent(data: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
renterId?: string | null
|
||||
sessionId?: string
|
||||
path?: string
|
||||
metadata?: Record<string, string | number | boolean | null>
|
||||
}) {
|
||||
const vehicle = await prisma.vehicle.findFirst({
|
||||
where: {
|
||||
id: data.vehicleId,
|
||||
isPublished: true,
|
||||
company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
},
|
||||
select: { companyId: true },
|
||||
})
|
||||
|
||||
return prisma.marketplaceFunnelEvent.create({
|
||||
data: {
|
||||
eventName: data.eventName,
|
||||
companyId: vehicle?.companyId ?? null,
|
||||
companySlug: data.companySlug,
|
||||
vehicleId: data.vehicleId,
|
||||
renterId: data.renterId ?? null,
|
||||
sessionId: data.sessionId,
|
||||
path: data.path,
|
||||
metadata: (data.metadata ?? {}) as any,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyPage(slug: string) {
|
||||
return prisma.company.findFirst({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
include: {
|
||||
brand: true,
|
||||
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
|
||||
offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
}
|
||||
|
||||
export async function findCompanyReviews(companyId: string) {
|
||||
return prisma.review.findMany({
|
||||
where: { companyId, isPublished: true },
|
||||
include: { renter: { select: { firstName: true, lastName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyVehicles(companyId: string) {
|
||||
return prisma.vehicle.findMany({
|
||||
where: { companyId, isPublished: true, status: 'AVAILABLE' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleById(slug: string, vehicleId: string) {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
return prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyOffers(companyId: string) {
|
||||
return prisma.offer.findMany({
|
||||
where: { companyId, isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationByReviewToken(token: string) {
|
||||
return prisma.reservation.findUnique({
|
||||
where: { reviewToken: token },
|
||||
include: {
|
||||
vehicle: { select: { make: true, model: true, year: true, photos: true } },
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true } } } },
|
||||
review: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationForReviewSubmit(token: string) {
|
||||
return prisma.reservation.findUnique({
|
||||
where: { reviewToken: token },
|
||||
include: { review: { select: { id: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createReview(data: {
|
||||
reservationId: string; companyId: string; renterId?: string | null
|
||||
overallRating: number; vehicleRating?: number; serviceRating?: number; comment?: string
|
||||
}) {
|
||||
return prisma.review.create({
|
||||
data: { ...data, renterId: data.renterId ?? undefined, isPublished: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function invalidateReviewToken(reservationId: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { reviewToken: null } })
|
||||
}
|
||||
|
||||
export async function findOfferByCode(code: string) {
|
||||
return prisma.offer.findFirst({
|
||||
where: { promoCode: code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Router } from 'express'
|
||||
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import * as service from './storefront.service'
|
||||
import {
|
||||
paginationSchema, searchSchema, companiesQuerySchema, storefrontReservationSchema,
|
||||
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
|
||||
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
|
||||
} from './storefront.schemas'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
router.get('/offers', async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getPublicOffers())
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cities', async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getCities())
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/companies', async (req, res, next) => {
|
||||
try {
|
||||
const { city, hasOffer } = parseQuery(companiesQuerySchema, req)
|
||||
const { page, pageSize } = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.getListedCompanies({ city, hasOffer, page, pageSize }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const filters = parseQuery(searchSchema, req)
|
||||
const pagination = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.searchVehicles({ ...filters, ...pagination }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(storefrontReservationSchema, req)
|
||||
const result = await service.createStorefrontReservation(body)
|
||||
created(res, result)
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/events', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(marketplaceFunnelEventSchema, req)
|
||||
ok(res, await service.trackStorefrontFunnelEvent(body, req.renterId))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
ok(res, await service.getReviewContext(token))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
const body = parseBody(reviewBodySchema, req)
|
||||
const result = await service.submitReview(token, body)
|
||||
created(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseParams(offerCodeParamSchema, req)
|
||||
ok(res, await service.validateOfferCode(code))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyPage(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Storefront data is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/reviews', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyReviews(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyVehicles(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
ok(res, await service.getVehicleDetail(slug, id))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
|
||||
ok(res, await service.getStorefrontVehicleAvailability(slug, id, { startDate, endDate }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyOffers(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { storefrontReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './storefront.schemas'
|
||||
|
||||
describe('storefront.schemas', () => {
|
||||
it('coerces pagination and caps untrusted public page sizes', () => {
|
||||
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
|
||||
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
|
||||
expect(() => paginationSchema.parse({ pageSize: '500' })).toThrow()
|
||||
})
|
||||
|
||||
it('trims search filters and coerces maxPrice', () => {
|
||||
expect(searchSchema.parse({ city: ' Rabat ', maxPrice: '500', dropoffMode: 'different' })).toEqual({
|
||||
city: 'Rabat',
|
||||
maxPrice: 500,
|
||||
dropoffMode: 'different',
|
||||
})
|
||||
expect(() => searchSchema.parse({ dropoffMode: 'teleport' })).toThrow()
|
||||
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
|
||||
})
|
||||
|
||||
it('defaults storefront reservation language while keeping date and email validation strict', () => {
|
||||
const parsed = storefrontReservationSchema.parse({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(parsed.language).toBe('fr')
|
||||
expect(() => storefrontReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||
expect(() => storefrontReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||
})
|
||||
|
||||
it('validates review ratings as bounded integers', () => {
|
||||
expect(reviewBodySchema.parse({ overallRating: 5, comment: 'Clean car' })).toEqual({ overallRating: 5, comment: 'Clean car' })
|
||||
expect(() => reviewBodySchema.parse({ overallRating: 6 })).toThrow()
|
||||
expect(() => reviewBodySchema.parse({ overallRating: 4.5 })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const searchSchema = z.object({
|
||||
city: z.string().trim().max(100).optional(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
dropoffLocation: z.string().trim().max(100).optional(),
|
||||
dropoffMode: z.enum(['same', 'different']).optional(),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
category: z.string().trim().max(50).optional(),
|
||||
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
|
||||
transmission: z.string().trim().max(20).optional(),
|
||||
make: z.string().trim().max(60).optional(),
|
||||
model: z.string().trim().max(60).optional(),
|
||||
})
|
||||
|
||||
export const companiesQuerySchema = z.object({
|
||||
city: z.string().trim().max(100).optional(),
|
||||
hasOffer: z.string().optional(),
|
||||
})
|
||||
|
||||
export const vehicleAvailabilityQuerySchema = z.object({
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const storefrontReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100).optional(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
// Contact info — collected at reservation time
|
||||
phone: z.string().min(1).max(30).optional(),
|
||||
// Identity & license — optional at reservation, collected at pickup
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: z.string().min(1).max(100).optional(),
|
||||
licenseCategory: z.string().min(1).max(20).optional(),
|
||||
internationalLicenseNumber: z.string().trim().max(100).optional(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
returnLocation: z.string().trim().max(100).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
})
|
||||
|
||||
export const marketplaceFunnelEventSchema = z.object({
|
||||
eventName: z.enum([
|
||||
'booking_form_viewed',
|
||||
'trip_dates_selected',
|
||||
'contact_details_started',
|
||||
'driver_details_expanded',
|
||||
'booking_request_submitted',
|
||||
'booking_request_failed',
|
||||
'booking_request_success',
|
||||
]),
|
||||
companySlug: z.string().trim().max(100),
|
||||
vehicleId: z.string().cuid(),
|
||||
sessionId: z.string().trim().max(100).optional(),
|
||||
path: z.string().trim().max(500).optional(),
|
||||
metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
|
||||
})
|
||||
|
||||
export const reviewBodySchema = z.object({
|
||||
overallRating: z.number().int().min(1).max(5),
|
||||
vehicleRating: z.number().int().min(1).max(5).optional(),
|
||||
serviceRating: z.number().int().min(1).max(5).optional(),
|
||||
comment: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const slugParamSchema = z.object({ slug: z.string() })
|
||||
export const vehicleParamSchema = z.object({ slug: z.string(), id: z.string() })
|
||||
export const reviewTokenSchema = z.object({ token: z.string() })
|
||||
export const offerCodeParamSchema = z.object({ code: z.string() })
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./storefront.repo', () => ({
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
findCompanyVehicles: vi.fn(),
|
||||
findVehicleById: vi.fn(),
|
||||
findCompanyOffers: vi.fn(),
|
||||
findOfferByCode: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
storefrontReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
||||
}))
|
||||
|
||||
import * as repo from './storefront.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './storefront.service'
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('storefront.service public page edges', () => {
|
||||
it('enriches company page vehicles with availability without changing company metadata', async () => {
|
||||
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
|
||||
vi.mocked(repo.findCompanyPage).mockResolvedValue({
|
||||
id: 'company_1',
|
||||
name: 'Atlas Cars',
|
||||
vehicles: [{ id: 'vehicle_1', make: 'Dacia' }, { id: 'vehicle_2', make: 'Renault' }],
|
||||
} as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary)
|
||||
.mockResolvedValueOnce({ available: false, status: 'RESERVED', nextAvailableAt, blockingReason: 'RESERVATION' })
|
||||
.mockResolvedValueOnce({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await getCompanyPage('atlas-cars')
|
||||
|
||||
expect(result).toMatchObject({ id: 'company_1', name: 'Atlas Cars' })
|
||||
expect(result.vehicles).toEqual([
|
||||
expect.objectContaining({ id: 'vehicle_1', availability: false, availabilityStatus: 'RESERVED', nextAvailableAt }),
|
||||
expect.objectContaining({ id: 'vehicle_2', availability: true, availabilityStatus: 'AVAILABLE', nextAvailableAt: null }),
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when a public company page cannot be found', async () => {
|
||||
vi.mocked(repo.findCompanyPage).mockResolvedValue(null)
|
||||
await expect(getCompanyPage('missing-company')).rejects.toThrow('Company not found')
|
||||
})
|
||||
|
||||
it('resolves public company child resources through the company id, not the slug directly', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue({ id: 'company_1' } as any)
|
||||
vi.mocked(repo.findCompanyReviews).mockResolvedValue([{ id: 'review_1' }] as any)
|
||||
vi.mocked(repo.findCompanyVehicles).mockResolvedValue([{ id: 'vehicle_1' }] as any)
|
||||
vi.mocked(repo.findCompanyOffers).mockResolvedValue([{ id: 'offer_1' }] as any)
|
||||
|
||||
await expect(getCompanyReviews('atlas-cars')).resolves.toEqual([{ id: 'review_1' }])
|
||||
await expect(getCompanyVehicles('atlas-cars')).resolves.toEqual([{ id: 'vehicle_1' }])
|
||||
await expect(getCompanyOffers('atlas-cars')).resolves.toEqual([{ id: 'offer_1' }])
|
||||
|
||||
expect(repo.findCompanyReviews).toHaveBeenCalledWith('company_1')
|
||||
expect(repo.findCompanyVehicles).toHaveBeenCalledWith('company_1')
|
||||
expect(repo.findCompanyOffers).toHaveBeenCalledWith('company_1')
|
||||
})
|
||||
|
||||
it('adds availability to vehicle detail responses', async () => {
|
||||
const nextAvailableAt = new Date('2026-12-01T00:00:00.000Z')
|
||||
vi.mocked(repo.findVehicleById).mockResolvedValue({ id: 'vehicle_1', make: 'Hyundai' } as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'MAINTENANCE', nextAvailableAt, blockingReason: 'BLOCK' })
|
||||
|
||||
await expect(getVehicleDetail('atlas-cars', 'vehicle_1')).resolves.toEqual(expect.objectContaining({
|
||||
id: 'vehicle_1',
|
||||
availability: false,
|
||||
availabilityStatus: 'MAINTENANCE',
|
||||
nextAvailableAt,
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects exhausted offer codes before returning them as valid', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ code: 'SUMMER', maxRedemptions: 10, redemptionCount: 10 } as any)
|
||||
|
||||
await expect(validateOfferCode('SUMMER')).rejects.toThrow('maximum redemptions')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { storefrontReservationEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './storefront.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 getStorefrontVehicleAvailability(
|
||||
slug: string,
|
||||
vehicleId: string,
|
||||
params: { startDate: string; endDate: string },
|
||||
) {
|
||||
const vehicle = await repo.findVehicleForStorefront(vehicleId, slug)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
const endDate = new Date(params.endDate)
|
||||
|
||||
if (endDate <= startDate) {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
companyId: vehicle.companyId,
|
||||
range: { startDate, endDate },
|
||||
})
|
||||
|
||||
return {
|
||||
available: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
blockingReason: availability.blockingReason,
|
||||
}
|
||||
}
|
||||
|
||||
function generateBookingReference() {
|
||||
const year = new Date().getUTCFullYear()
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||
return `BK-${year}-${random}`
|
||||
}
|
||||
|
||||
export async function createStorefrontReservation(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.findVehicleForStorefrontById(body.vehicleId)
|
||||
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.createStorefrontReservation({
|
||||
companyId: vehicle.companyId,
|
||||
vehicleId: body.vehicleId,
|
||||
customerId: customer.id,
|
||||
startDate,
|
||||
endDate,
|
||||
pickupLocation,
|
||||
returnLocation,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount,
|
||||
notes: body.notes,
|
||||
bookingReference: generateBookingReference(),
|
||||
})
|
||||
|
||||
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: storefrontReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: storefrontReservationEmail.html(emailOpts, lang),
|
||||
text: storefrontReservationEmail.text(emailOpts, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
sendNotification({
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'New Storefront 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,
|
||||
bookingReference: (reservation as any).bookingReference ?? null,
|
||||
companyName,
|
||||
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
status: 'PENDING',
|
||||
message: 'The company will review your request.',
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackStorefrontFunnelEvent(body: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
sessionId?: string
|
||||
path?: string
|
||||
metadata?: Record<string, string | number | boolean | null>
|
||||
}, renterId?: string) {
|
||||
await repo.createStorefrontFunnelEvent({
|
||||
...body,
|
||||
renterId: renterId ?? null,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
|
||||
|
||||
vi.mock('./storefront.repo', () => ({
|
||||
findPublicOffers: vi.fn(),
|
||||
findCitiesFromCompanies: vi.fn(),
|
||||
findListedCompanies: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleForStorefront: vi.fn(),
|
||||
findVehicleForStorefrontById: vi.fn(),
|
||||
upsertMarketplaceCustomer: vi.fn(),
|
||||
createStorefrontReservation: vi.fn(),
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
findCompanyVehicles: vi.fn(),
|
||||
findVehicleById: vi.fn(),
|
||||
findCompanyOffers: vi.fn(),
|
||||
findReservationByReviewToken: vi.fn(),
|
||||
findReservationForReviewSubmit: vi.fn(),
|
||||
createReview: vi.fn(),
|
||||
invalidateReviewToken: vi.fn(),
|
||||
findOfferByCode: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({
|
||||
getVehicleAvailabilitySummary: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/notificationService', () => ({
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
storefrontReservationEmail: {
|
||||
subject: vi.fn(() => 'Subject'),
|
||||
html: vi.fn(() => '<html>'),
|
||||
text: vi.fn(() => 'text'),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as repo from './storefront.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import {
|
||||
getCities, searchVehicles, createStorefrontReservation,
|
||||
getReviewContext, submitReview, validateOfferCode,
|
||||
} from './storefront.service'
|
||||
|
||||
const SLUG = 'test-company'
|
||||
|
||||
function makeVehicle(overrides: object = {}) {
|
||||
return {
|
||||
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
|
||||
isPublished: true, status: 'AVAILABLE', companyId: 'co-1',
|
||||
pickupLocations: ['Casablanca'],
|
||||
allowDifferentDropoff: false,
|
||||
dropoffLocations: [],
|
||||
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getCities', () => {
|
||||
it('deduplicates and sorts cities case-insensitively', async () => {
|
||||
vi.mocked(repo.findCitiesFromCompanies).mockResolvedValue([
|
||||
{ brand: { publicCity: 'Casablanca' } },
|
||||
{ brand: { publicCity: 'casablanca' } },
|
||||
{ brand: { publicCity: 'Rabat' } },
|
||||
{ brand: null },
|
||||
] as any)
|
||||
|
||||
const result = await getCities()
|
||||
expect(result).toEqual(['Casablanca', 'Rabat'])
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('searchVehicles', () => {
|
||||
it('returns vehicles enriched with availability data', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await searchVehicles({ city: 'Casablanca' })
|
||||
|
||||
expect(repo.findPublishedVehicles).toHaveBeenCalledOnce()
|
||||
expect(result[0].availability).toBe(true)
|
||||
expect(result[0].availabilityStatus).toBe('AVAILABLE')
|
||||
})
|
||||
|
||||
it('passes date range to availability service when provided', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' })
|
||||
|
||||
const result = await searchVehicles({ startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z' })
|
||||
|
||||
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', {
|
||||
companyId: 'co-1',
|
||||
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') },
|
||||
})
|
||||
expect(result[0].availability).toBe(false)
|
||||
})
|
||||
|
||||
it('filters out vehicles that do not support different drop-off when requested', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([
|
||||
makeVehicle({ id: 'same-only' }),
|
||||
makeVehicle({ id: 'one-way', allowDifferentDropoff: true, dropoffLocations: ['Rabat'] }),
|
||||
] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await searchVehicles({ pickupLocation: 'Casablanca', dropoffLocation: 'Rabat', dropoffMode: 'different' })
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('one-way')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createStorefrontReservation', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1', companySlug: SLUG,
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
phone: '+212600000000',
|
||||
dateOfBirth: '1990-01-01T00:00:00.000Z',
|
||||
nationality: 'Moroccan',
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
licenseExpiry: '2030-01-01T00:00:00.000Z',
|
||||
licenseIssuedAt: '2015-01-01T00:00:00.000Z',
|
||||
licenseCountry: 'Morocco',
|
||||
licenseCategory: 'B',
|
||||
startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z',
|
||||
}
|
||||
|
||||
it('creates reservation and returns reservationId', async () => {
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(repo.createStorefrontReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
||||
|
||||
const result = await createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(result.bookingReference).toBe('BK-2026-ABC123')
|
||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
}))
|
||||
expect(repo.createStorefrontReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}))
|
||||
})
|
||||
|
||||
it('throws NotFoundError when vehicle not found', async () => {
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(null)
|
||||
|
||||
await expect(createStorefrontReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws AppError when vehicle is unavailable', async () => {
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
|
||||
await expect(createStorefrontReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
|
||||
|
||||
await expect(
|
||||
createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
).rejects.toMatchObject({ error: 'same_dropoff_required' })
|
||||
})
|
||||
|
||||
it('throws AppError for invalid date range', async () => {
|
||||
await expect(
|
||||
createStorefrontReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getReviewContext', () => {
|
||||
it('returns review context for a valid unreviewd token', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({
|
||||
id: 'r-1', companyId: 'co-1', renterId: null, reviewToken: 'tok',
|
||||
vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: ['photo.jpg'] },
|
||||
company: { name: 'Test Co', brand: { displayName: 'Test Co', logoUrl: 'logo.png' } },
|
||||
review: null,
|
||||
} as any)
|
||||
|
||||
const result = await getReviewContext('tok')
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(result.vehiclePhoto).toBe('photo.jpg')
|
||||
})
|
||||
|
||||
it('throws NotFoundError for unknown token', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue(null)
|
||||
await expect(getReviewContext('bad')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws ConflictError when already reviewed', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({
|
||||
id: 'r-1', companyId: 'co-1', renterId: null,
|
||||
vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: [] },
|
||||
company: { name: 'Test Co', brand: null },
|
||||
review: { id: 'rev-1' },
|
||||
} as any)
|
||||
|
||||
await expect(getReviewContext('tok')).rejects.toBeInstanceOf(ConflictError)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('submitReview', () => {
|
||||
it('creates review and invalidates token', async () => {
|
||||
vi.mocked(repo.findReservationForReviewSubmit).mockResolvedValue({ id: 'r-1', companyId: 'co-1', renterId: null, review: null } as any)
|
||||
vi.mocked(repo.createReview).mockResolvedValue({ id: 'rev-1' } as any)
|
||||
vi.mocked(repo.invalidateReviewToken).mockResolvedValue(undefined as any)
|
||||
|
||||
const result = await submitReview('tok', { overallRating: 5 })
|
||||
|
||||
expect(repo.createReview).toHaveBeenCalledOnce()
|
||||
expect(repo.invalidateReviewToken).toHaveBeenCalledWith('r-1')
|
||||
expect(result.reviewId).toBe('rev-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('validateOfferCode', () => {
|
||||
it('returns offer when code is valid and not exhausted', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||
const result = await validateOfferCode('PROMO10')
|
||||
expect((result as any).id).toBe('o-1')
|
||||
})
|
||||
|
||||
it('throws NotFoundError for unknown code', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue(null)
|
||||
await expect(validateOfferCode('BAD')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws ConflictError when max redemptions reached', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: 10, redemptionCount: 10 } as any)
|
||||
await expect(validateOfferCode('PROMO10')).rejects.toBeInstanceOf(ConflictError)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user