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

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:
root
2026-06-28 01:14:46 -04:00
parent bffda95a7c
commit 8aab968e09
147 changed files with 829 additions and 829 deletions
+4 -4
View File
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
// ─── Site config ───────────────────────────────────────────────
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
router.get('/site-config/storefront-homepage', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.getMarketplaceHomepage())
ok(res, await service.getStorefrontHomepage())
} catch (err) { next(err) }
})
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.patch('/site-config/storefront-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { homepage } = parseBody(homepageUpdateSchema, req)
ok(res, await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip))
ok(res, await service.updateStorefrontHomepage(homepage, req.admin.id, req.ip))
} catch (err) { next(err) }
})
+19 -19
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import type { MarketplaceHomepageContent, MarketplaceHomepageHowItWorksStep, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep, MarketplaceHomepageTestimonial } from '@rentaldrivego/types'
import type { StorefrontHomepageContent, StorefrontHomepageHowItWorksStep, StorefrontHomepageMetric, StorefrontHomepagePillar, StorefrontHomepageSectionType, StorefrontHomepageStep, StorefrontHomepageTestimonial } from '@rentaldrivego/types'
export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
@@ -175,35 +175,35 @@ export const adminCompanyUpdateSchema = z.object({
}).optional(),
})
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
const marketplaceHowItWorksStepSchema: z.ZodType<MarketplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
const marketplaceTestimonialSchema: z.ZodType<MarketplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const storefrontMetricSchema: z.ZodType<StorefrontHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const storefrontPillarSchema: z.ZodType<StorefrontHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const storefrontStepSchema: z.ZodType<StorefrontHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
const storefrontHowItWorksStepSchema: z.ZodType<StorefrontHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
const storefrontTestimonialSchema: z.ZodType<StorefrontHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
const storefrontSectionSchema: z.ZodType<StorefrontHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
sections: z.array(marketplaceSectionSchema).min(1),
const storefrontHomepageContentSchema: z.ZodType<StorefrontHomepageContent> = z.object({
sections: z.array(storefrontSectionSchema).min(1),
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().min(1),
startTrial: z.string().min(1), exploreVehicles: z.string().min(1),
surfaceLabel: z.string().min(1), surfaceTitle: z.string().min(1), surfaceBody: z.string().min(1),
liveLabel: z.string().min(1), trustedFleets: z.string().min(1), brandedFlows: z.string().min(1), multiTenant: z.string().min(1),
companyKicker: z.string().min(1), companyTitle: z.string().min(1), companyBody: z.string().min(1),
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
pillars: z.array(marketplacePillarSchema).length(3),
metrics: z.array(marketplaceMetricSchema).length(3),
pillars: z.array(storefrontPillarSchema).length(3),
metrics: z.array(storefrontMetricSchema).length(3),
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(marketplaceHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(marketplaceTestimonialSchema).min(1),
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(storefrontHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(storefrontStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(storefrontTestimonialSchema).min(1),
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: z.string().min(1),
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
})
export const marketplaceHomepageConfigSchema = z.object({
en: marketplaceHomepageContentSchema,
fr: marketplaceHomepageContentSchema,
ar: marketplaceHomepageContentSchema,
export const storefrontHomepageConfigSchema = z.object({
en: storefrontHomepageContentSchema,
fr: storefrontHomepageContentSchema,
ar: storefrontHomepageContentSchema,
})
export const idParamSchema = z.object({
@@ -300,7 +300,7 @@ export const billingRefundSchema = z.object({
})
export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema,
homepage: storefrontHomepageConfigSchema,
})
export const pricingUpdateSchema = z.object({
+6 -6
View File
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { getStorefrontHomepageContent, saveStorefrontHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
@@ -402,16 +402,16 @@ export function issueBillingRefund(
return billingService.issueRefund(invoiceId, data, adminId, ip)
}
export function getMarketplaceHomepage() {
return getMarketplaceHomepageContent()
export function getStorefrontHomepage() {
return getStorefrontHomepageContent()
}
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveMarketplaceHomepageContent(homepage)
export async function updateStorefrontHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveStorefrontHomepageContent(homepage)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'MarketplaceHomepage',
resource: 'StorefrontHomepage',
after: toAuditJson(saved),
ipAddress: ip,
userAgent: undefined,
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
planFeature: { findMany: vi.fn() },
},
}))
vi.mock('../../services/platformContentService', () => ({ getMarketplaceHomepageContent: vi.fn() }))
vi.mock('../../services/platformContentService', () => ({ getStorefrontHomepageContent: vi.fn() }))
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
+2 -2
View File
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
import { getStorefrontHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
@@ -53,7 +53,7 @@ function generateBookingReference() {
}
export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
return getStorefrontHomepageContent()
}
export async function getPlatformPricing() {
+1 -1
View File
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
}))
vi.mock('../../services/platformContentService', () => ({
getMarketplaceHomepageContent: vi.fn(),
getStorefrontHomepageContent: vi.fn(),
}))
import * as repo from './site.repo'
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { presentVehicleWithAvailability } from './marketplace.presenter'
import { presentVehicleWithAvailability } from './storefront.presenter'
describe('marketplace.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 }
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './marketplace.repo'
import * as repo from './storefront.repo'
describe('marketplace.repo public query and write boundaries', () => {
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'))
@@ -33,7 +33,7 @@ describe('marketplace.repo public query and write boundaries', () => {
vi.useRealTimers()
})
it('lists marketplace cities only from active listed companies with a public city', async () => {
it('lists storefront cities only from active listed companies with a public city', async () => {
await repo.findCitiesFromCompanies()
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
@@ -45,8 +45,8 @@ describe('marketplace.repo public query and write boundaries', () => {
})
})
it('requires published vehicles and an active company when loading marketplace vehicle details', async () => {
await repo.findVehicleForMarketplace('vehicle_1', 'atlas')
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: {
@@ -58,7 +58,7 @@ describe('marketplace.repo public query and write boundaries', () => {
})
})
it('patches marketplace customer address fields without deleting existing address metadata', async () => {
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' },
@@ -86,11 +86,11 @@ describe('marketplace.repo public query and write boundaries', () => {
})
})
it('creates marketplace reservations as draft marketplace-sourced records', async () => {
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.createMarketplaceReservation({
await repo.createStorefrontReservation({
companyId: 'company_1',
vehicleId: 'vehicle_1',
customerId: 'customer_1',
@@ -41,14 +41,14 @@ export async function findPublishedVehicles(where: any) {
})
}
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
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 findVehicleForMarketplaceById(vehicleId: string) {
export async function findVehicleForStorefrontById(vehicleId: string) {
return prisma.vehicle.findFirst({
where: {
id: vehicleId,
@@ -115,7 +115,7 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
return prisma.customer.update({ where: { id: existing.id }, data: payload })
}
export async function createMarketplaceReservation(data: {
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
@@ -125,7 +125,7 @@ export async function createMarketplaceReservation(data: {
})
}
export async function createMarketplaceFunnelEvent(data: {
export async function createStorefrontFunnelEvent(data: {
eventName: string
companySlug: string
vehicleId: string
@@ -3,12 +3,12 @@ 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 './marketplace.service'
import * as service from './storefront.service'
import {
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
paginationSchema, searchSchema, companiesQuerySchema, storefrontReservationSchema,
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
} from './marketplace.schemas'
} from './storefront.schemas'
const router = Router()
router.use(optionalRenterAuth)
@@ -55,8 +55,8 @@ router.get('/search', async (req, res, next) => {
router.post('/reservations', async (req, res, next) => {
try {
const body = parseBody(marketplaceReservationSchema, req)
const result = await service.createMarketplaceReservation(body)
const body = parseBody(storefrontReservationSchema, req)
const result = await service.createStorefrontReservation(body)
created(res, result)
} catch (err) {
if (isDatabaseUnavailableError(err)) {
@@ -69,7 +69,7 @@ router.post('/reservations', async (req, res, next) => {
router.post('/events', async (req, res, next) => {
try {
const body = parseBody(marketplaceFunnelEventSchema, req)
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId))
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 })
@@ -112,7 +112,7 @@ router.get('/:slug', async (req, res, next) => {
ok(res, await service.getCompanyPage(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
return res.status(503).json({ error: 'database_unavailable', message: 'Storefront data is temporarily unavailable', statusCode: 503 })
}
next(err)
}
@@ -154,7 +154,7 @@ 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.getMarketplaceVehicleAvailability(slug, id, { startDate, endDate }))
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 })
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { marketplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './marketplace.schemas'
import { storefrontReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './storefront.schemas'
describe('marketplace.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 })
@@ -18,8 +18,8 @@ describe('marketplace.schemas', () => {
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
})
it('defaults marketplace reservation language while keeping date and email validation strict', () => {
const parsed = marketplaceReservationSchema.parse({
it('defaults storefront reservation language while keeping date and email validation strict', () => {
const parsed = storefrontReservationSchema.parse({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',
@@ -30,8 +30,8 @@ describe('marketplace.schemas', () => {
})
expect(parsed.language).toBe('fr')
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
expect(() => storefrontReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => storefrontReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
})
it('validates review ratings as bounded integers', () => {
@@ -29,7 +29,7 @@ export const vehicleAvailabilityQuerySchema = z.object({
endDate: z.string().datetime(),
})
export const marketplaceReservationSchema = z.object({
export const storefrontReservationSchema = z.object({
vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100).optional(),
firstName: z.string().min(1).max(100),
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./marketplace.repo', () => ({
vi.mock('./storefront.repo', () => ({
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
@@ -12,16 +12,16 @@ vi.mock('./marketplace.repo', () => ({
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
vi.mock('../../lib/emailTranslations', () => ({
marketplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
storefrontReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
}))
import * as repo from './marketplace.repo'
import * as repo from './storefront.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './marketplace.service'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './storefront.service'
beforeEach(() => vi.clearAllMocks())
describe('marketplace.service public page edges', () => {
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({
@@ -1,8 +1,8 @@
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'
import { storefrontReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './storefront.repo'
function normalizeLocation(value?: string | null) {
const normalized = value?.trim()
@@ -120,12 +120,12 @@ export async function searchVehicles(params: {
}))
}
export async function getMarketplaceVehicleAvailability(
export async function getStorefrontVehicleAvailability(
slug: string,
vehicleId: string,
params: { startDate: string; endDate: string },
) {
const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug)
const vehicle = await repo.findVehicleForStorefront(vehicleId, slug)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const startDate = new Date(params.startDate)
@@ -154,7 +154,7 @@ function generateBookingReference() {
return `BK-${year}-${random}`
}
export async function createMarketplaceReservation(body: {
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
@@ -170,7 +170,7 @@ export async function createMarketplaceReservation(body: {
throw new AppError('End date must be after start date', 400, 'invalid_dates')
}
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
const vehicle = await repo.findVehicleForStorefrontById(body.vehicleId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation)
@@ -218,7 +218,7 @@ export async function createMarketplaceReservation(body: {
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const totalAmount = vehicle.dailyRate * totalDays
const reservation = await repo.createMarketplaceReservation({
const reservation = await repo.createStorefrontReservation({
companyId: vehicle.companyId,
vehicleId: body.vehicleId,
customerId: customer.id,
@@ -245,14 +245,14 @@ export async function createMarketplaceReservation(body: {
sendTransactionalEmail({
to: body.email,
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: marketplaceReservationEmail.html(emailOpts, lang),
text: marketplaceReservationEmail.text(emailOpts, lang),
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 Marketplace Reservation Request',
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'],
@@ -270,7 +270,7 @@ export async function createMarketplaceReservation(body: {
}
}
export async function trackMarketplaceFunnelEvent(body: {
export async function trackStorefrontFunnelEvent(body: {
eventName: string
companySlug: string
vehicleId: string
@@ -278,7 +278,7 @@ export async function trackMarketplaceFunnelEvent(body: {
path?: string
metadata?: Record<string, string | number | boolean | null>
}, renterId?: string) {
await repo.createMarketplaceFunnelEvent({
await repo.createStorefrontFunnelEvent({
...body,
renterId: renterId ?? null,
})
@@ -1,15 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
vi.mock('./marketplace.repo', () => ({
vi.mock('./storefront.repo', () => ({
findPublicOffers: vi.fn(),
findCitiesFromCompanies: vi.fn(),
findListedCompanies: vi.fn(),
findPublishedVehicles: vi.fn(),
findVehicleForMarketplace: vi.fn(),
findVehicleForMarketplaceById: vi.fn(),
findVehicleForStorefront: vi.fn(),
findVehicleForStorefrontById: vi.fn(),
upsertMarketplaceCustomer: vi.fn(),
createMarketplaceReservation: vi.fn(),
createStorefrontReservation: vi.fn(),
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
}))
vi.mock('../../lib/emailTranslations', () => ({
marketplaceReservationEmail: {
storefrontReservationEmail: {
subject: vi.fn(() => 'Subject'),
html: vi.fn(() => '<html>'),
text: vi.fn(() => 'text'),
},
}))
import * as repo from './marketplace.repo'
import * as repo from './storefront.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import {
getCities, searchVehicles, createMarketplaceReservation,
getCities, searchVehicles, createStorefrontReservation,
getReviewContext, submitReview, validateOfferCode,
} from './marketplace.service'
} from './storefront.service'
const SLUG = 'test-company'
@@ -119,7 +119,7 @@ describe('searchVehicles', () => {
})
// ────────────────────────────────────────────────────────────────────────────
describe('createMarketplaceReservation', () => {
describe('createStorefrontReservation', () => {
const baseBody = {
vehicleId: 'v-1', companySlug: SLUG,
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
@@ -137,12 +137,12 @@ describe('createMarketplaceReservation', () => {
}
it('creates reservation and returns reservationId', async () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
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.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
vi.mocked(repo.createStorefrontReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
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({
@@ -150,36 +150,36 @@ describe('createMarketplaceReservation', () => {
fullAddress: '123 Avenue Hassan II, Casablanca',
driverLicense: 'DL-123456',
}))
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
expect(repo.createStorefrontReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
}))
})
it('throws NotFoundError when vehicle not found', async () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null)
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(null)
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
await expect(createStorefrontReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
})
it('throws AppError when vehicle is unavailable', async () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
await expect(createStorefrontReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('rejects different return locations when the vehicle requires same drop-off', async () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
await expect(
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
).rejects.toMatchObject({ error: 'same_dropoff_required' })
})
it('throws AppError for invalid date range', async () => {
await expect(
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
createStorefrontReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
).rejects.toMatchObject({ error: 'invalid_dates' })
})
})