chore: wire Carplace into dev and production stacks
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
This commit is contained in:
+6
-4
@@ -29,7 +29,7 @@ import customersRouter from './modules/customers/customer.routes'
|
||||
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
||||
import companiesRouter from './modules/companies/company.routes'
|
||||
import reservationsRouter from './modules/reservations/reservation.routes'
|
||||
import storefrontRouter from './modules/storefront/storefront.routes'
|
||||
import carplaceRouter from './modules/carplace/carplace.routes'
|
||||
import siteRouter from './modules/site/site.routes'
|
||||
import reviewsRouter from './modules/reviews/review.routes'
|
||||
import complaintsRouter from './modules/complaints/complaint.routes'
|
||||
@@ -96,8 +96,10 @@ const routeDocs = [
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/storefront/cities`, description: 'Storefront cities' },
|
||||
{ method: 'GET', path: `${v1}/storefront/search`, description: 'Storefront search' },
|
||||
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
|
||||
{ method: 'GET', path: `${v1}/carplace/search`, description: 'Carplace paginated vehicle search' },
|
||||
{ method: 'POST', path: `${v1}/carplace/quotes`, description: 'Carplace availability and price estimate' },
|
||||
{ method: 'POST', path: `${v1}/carplace/reservations`, description: 'Create Carplace reservation request' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
@@ -188,7 +190,7 @@ export function createApp() {
|
||||
app.use(`${v1}/admin/auth`, authLimiter)
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
app.use(`${v1}/storefront`, publicLimiter, storefrontRouter)
|
||||
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"storefrontHomepage": {
|
||||
"carplaceHomepage": {
|
||||
"en": {
|
||||
"sections": [
|
||||
"hero",
|
||||
@@ -13,13 +13,13 @@
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Storefront discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared storefront, and every booking ends on the company’s own branded checkout.",
|
||||
"heroTitle": "Carplace discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared Carplace, and every booking ends on the company’s own branded checkout.",
|
||||
"startTrial": "Start free trial",
|
||||
"exploreVehicles": "Explore vehicles",
|
||||
"surfaceLabel": "Storefront surface",
|
||||
"surfaceLabel": "Carplace surface",
|
||||
"surfaceTitle": "Designed for two audiences at once.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The storefront should show both without feeling like a template.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The Carplace should show both without feeling like a template.",
|
||||
"liveLabel": "Live network",
|
||||
"trustedFleets": "Trusted fleets",
|
||||
"brandedFlows": "Branded booking flows",
|
||||
@@ -29,11 +29,11 @@
|
||||
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
||||
"renterKicker": "Renter experience",
|
||||
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
||||
"renterBody": "The storefront works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||
"renterBody": "The Carplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Unified publishing",
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into storefront discovery and branded booking pages."
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into Carplace discovery and branded booking pages."
|
||||
},
|
||||
{
|
||||
"title": "Qualified discovery",
|
||||
@@ -47,7 +47,7 @@
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Shared storefront visibility"
|
||||
"label": "Shared Carplace visibility"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
@@ -61,7 +61,7 @@
|
||||
"featureLabel": "What the product covers",
|
||||
"features": [
|
||||
"Fleet management with multi-photo uploads",
|
||||
"Storefront offers and redirect booking flow",
|
||||
"Carplace offers and redirect booking flow",
|
||||
"Branded public booking site per company",
|
||||
"Customer CRM, analytics, and billing controls"
|
||||
],
|
||||
@@ -76,7 +76,7 @@
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Add Your Fleet",
|
||||
"description": "Upload vehicle photos, set prices, and create offers visible on the storefront."
|
||||
"description": "Upload vehicle photos, set prices, and create offers visible on the Carplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
@@ -94,7 +94,7 @@
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publish vehicles and offers",
|
||||
"body": "Upload photos once and control what appears on the storefront and branded site."
|
||||
"body": "Upload photos once and control what appears on the Carplace and branded site."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
@@ -104,8 +104,8 @@
|
||||
],
|
||||
"stepLabel": "Step",
|
||||
"readyKicker": "Ready to launch",
|
||||
"readyTitle": "The storefront homepage should explain the product in seconds.",
|
||||
"readyBody": "Use the storefront to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||
"readyTitle": "The Carplace homepage should explain the product in seconds.",
|
||||
"readyBody": "Use the Carplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||
"viewPricing": "View pricing",
|
||||
"createWorkspace": "Create workspace"
|
||||
},
|
||||
@@ -122,13 +122,13 @@
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Une vitrine storefront plus claire et plus percutante.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une storefront commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||
"heroTitle": "Une vitrine Carplace plus claire et plus percutante.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une Carplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||
"startTrial": "Commencer l’essai gratuit",
|
||||
"exploreVehicles": "Explorer les véhicules",
|
||||
"surfaceLabel": "Vitrine storefront",
|
||||
"surfaceLabel": "Vitrine Carplace",
|
||||
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La storefront doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La Carplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"liveLabel": "Réseau actif",
|
||||
"trustedFleets": "Flottes fiables",
|
||||
"brandedFlows": "Parcours de marque",
|
||||
@@ -138,11 +138,11 @@
|
||||
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
|
||||
"renterKicker": "Expérience client",
|
||||
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site d’entreprise.",
|
||||
"renterBody": "La storefront sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"renterBody": "La Carplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Publication unifiée",
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte storefront et les pages de réservation de marque."
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte Carplace et les pages de réservation de marque."
|
||||
},
|
||||
{
|
||||
"title": "Découverte qualifiée",
|
||||
@@ -156,7 +156,7 @@
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Visibilité storefront partagée"
|
||||
"label": "Visibilité Carplace partagée"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
@@ -170,30 +170,30 @@
|
||||
"featureLabel": "Ce que couvre le produit",
|
||||
"features": [
|
||||
"Gestion de flotte avec téléversement de plusieurs photos",
|
||||
"Offres storefront et redirection vers la réservation",
|
||||
"Offres Carplace et redirection vers la réservation",
|
||||
"Site public de réservation par entreprise",
|
||||
"CRM client, analytics et contrôle de facturation"
|
||||
],
|
||||
"howitworksKicker": "MISE EN ROUTE",
|
||||
"howitworksTitle": "Comment ça marche",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Créer un compte",
|
||||
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Ajouter votre flotte",
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la storefront."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "Commencer à vendre",
|
||||
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "Comment les entreprises démarrent",
|
||||
"CRM client, analytics et contrôle de facturation"
|
||||
],
|
||||
"howitworksKicker": "MISE EN ROUTE",
|
||||
"howitworksTitle": "Comment ça marche",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Créer un compte",
|
||||
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Ajouter votre flotte",
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la Carplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "Commencer à vendre",
|
||||
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "Comment les entreprises démarrent",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
@@ -203,7 +203,7 @@
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publiez véhicules et offres",
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la storefront et le site de marque."
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la Carplace et le site de marque."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
@@ -213,8 +213,8 @@
|
||||
],
|
||||
"stepLabel": "Étape",
|
||||
"readyKicker": "Prêt à démarrer",
|
||||
"readyTitle": "La page d’accueil storefront doit présenter le produit en quelques secondes.",
|
||||
"readyBody": "Utilisez la storefront pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||
"readyTitle": "La page d’accueil Carplace doit présenter le produit en quelques secondes.",
|
||||
"readyBody": "Utilisez la Carplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||
"viewPricing": "Voir les tarifs",
|
||||
"createWorkspace": "Créer l’espace"
|
||||
},
|
||||
@@ -281,28 +281,28 @@
|
||||
"إدارة الأسطول مع رفع عدة صور",
|
||||
"عروض السوق والتحويل إلى مسار الحجز",
|
||||
"موقع حجز عام مخصص لكل شركة",
|
||||
"إدارة العملاء والتحليلات والفوترة"
|
||||
],
|
||||
"howitworksKicker": "البدء",
|
||||
"howitworksTitle": "كيف يعمل",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "إنشاء حساب",
|
||||
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "أضف أسطولك",
|
||||
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "ابدأ البيع",
|
||||
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "كيف تبدأ الشركات",
|
||||
"إدارة العملاء والتحليلات والفوترة"
|
||||
],
|
||||
"howitworksKicker": "البدء",
|
||||
"howitworksTitle": "كيف يعمل",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "إنشاء حساب",
|
||||
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "أضف أسطولك",
|
||||
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "ابدأ البيع",
|
||||
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "كيف تبدأ الشركات",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatDate,
|
||||
storefrontReservationEmail,
|
||||
carplaceReservationEmail,
|
||||
resetPasswordEmail,
|
||||
signupEmail,
|
||||
} from './emailTranslations'
|
||||
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
|
||||
expect(html).toContain('45')
|
||||
})
|
||||
|
||||
it('includes optional contact phone in storefront reservation HTML when present', () => {
|
||||
const html = storefrontReservationEmail.html({
|
||||
it('includes optional contact phone in Carplace reservation HTML when present', () => {
|
||||
const html = carplaceReservationEmail.html({
|
||||
firstName: 'Yassine',
|
||||
vehicleYear: 2024,
|
||||
vehicleMake: 'Dacia',
|
||||
|
||||
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Storefront reservation request ─────────────────────────────────────────
|
||||
// ─── Carplace reservation request ─────────────────────────────────────────
|
||||
|
||||
export const storefrontReservationEmail = {
|
||||
export const carplaceReservationEmail = {
|
||||
subject: (vehicleName: string, lang: Lang) => t({
|
||||
en: `Reservation Request Received — ${vehicleName}`,
|
||||
fr: `Demande de réservation reçue — ${vehicleName}`,
|
||||
|
||||
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Limiter for public storefront and site endpoints (no auth)
|
||||
// Limiter for public carplace and site endpoints (no auth)
|
||||
export const publicLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
|
||||
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
|
||||
|
||||
// ─── Site config ───────────────────────────────────────────────
|
||||
|
||||
router.get('/site-config/storefront-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
router.get('/site-config/carplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getStorefrontHomepage())
|
||||
ok(res, await service.getCarplaceHomepage())
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/site-config/storefront-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
router.patch('/site-config/carplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { homepage } = parseBody(homepageUpdateSchema, req)
|
||||
ok(res, await service.updateStorefrontHomepage(homepage, req.admin.id, req.ip))
|
||||
ok(res, await service.updateCarplaceHomepage(homepage, req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import type { StorefrontHomepageContent, StorefrontHomepageHowItWorksStep, StorefrontHomepageMetric, StorefrontHomepagePillar, StorefrontHomepageSectionType, StorefrontHomepageStep, StorefrontHomepageTestimonial } from '@rentaldrivego/types'
|
||||
import type {
|
||||
CarplaceHomepageContent,
|
||||
CarplaceHomepageHowItWorksStep,
|
||||
CarplaceHomepageMetric,
|
||||
CarplaceHomepagePillar,
|
||||
CarplaceHomepageSectionType,
|
||||
CarplaceHomepageStep,
|
||||
CarplaceHomepageTestimonial,
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
@@ -154,7 +162,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.any().optional(),
|
||||
menuConfig: z.any().optional(),
|
||||
}).optional(),
|
||||
@@ -175,35 +183,35 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
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 carplaceMetricSchema: z.ZodType<CarplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
|
||||
const carplacePillarSchema: z.ZodType<CarplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
|
||||
const carplaceStepSchema: z.ZodType<CarplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
|
||||
const carplaceHowItWorksStepSchema: z.ZodType<CarplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
|
||||
const carplaceTestimonialSchema: z.ZodType<CarplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
|
||||
const carplaceSectionSchema: z.ZodType<CarplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
|
||||
|
||||
const storefrontHomepageContentSchema: z.ZodType<StorefrontHomepageContent> = z.object({
|
||||
sections: z.array(storefrontSectionSchema).min(1),
|
||||
const carplaceHomepageContentSchema: z.ZodType<CarplaceHomepageContent> = z.object({
|
||||
sections: z.array(carplaceSectionSchema).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(storefrontPillarSchema).length(3),
|
||||
metrics: z.array(storefrontMetricSchema).length(3),
|
||||
pillars: z.array(carplacePillarSchema).length(3),
|
||||
metrics: z.array(carplaceMetricSchema).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(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),
|
||||
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(carplaceHowItWorksStepSchema).min(1),
|
||||
stepsTitle: z.string().min(1), steps: z.array(carplaceStepSchema).length(3), stepLabel: z.string().min(1),
|
||||
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(carplaceTestimonialSchema).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 storefrontHomepageConfigSchema = z.object({
|
||||
en: storefrontHomepageContentSchema,
|
||||
fr: storefrontHomepageContentSchema,
|
||||
ar: storefrontHomepageContentSchema,
|
||||
export const carplaceHomepageConfigSchema = z.object({
|
||||
en: carplaceHomepageContentSchema,
|
||||
fr: carplaceHomepageContentSchema,
|
||||
ar: carplaceHomepageContentSchema,
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
@@ -300,7 +308,7 @@ export const billingRefundSchema = z.object({
|
||||
})
|
||||
|
||||
export const homepageUpdateSchema = z.object({
|
||||
homepage: storefrontHomepageConfigSchema,
|
||||
homepage: carplaceHomepageConfigSchema,
|
||||
})
|
||||
|
||||
export const pricingUpdateSchema = z.object({
|
||||
|
||||
@@ -3,7 +3,7 @@ import crypto from 'crypto'
|
||||
import { authenticator } from 'otplib'
|
||||
import { signActorToken } from '../../security/tokens'
|
||||
import qrcode from 'qrcode'
|
||||
import { getStorefrontHomepageContent, saveStorefrontHomepageContent } from '../../services/platformContentService'
|
||||
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } 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 getStorefrontHomepage() {
|
||||
return getStorefrontHomepageContent()
|
||||
export function getCarplaceHomepage() {
|
||||
return getCarplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function updateStorefrontHomepage(homepage: any, adminId: string, ip?: string) {
|
||||
const saved = await saveStorefrontHomepageContent(homepage)
|
||||
export async function updateCarplaceHomepage(homepage: any, adminId: string, ip?: string) {
|
||||
const saved = await saveCarplaceHomepageContent(homepage)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'UPDATE',
|
||||
resource: 'StorefrontHomepage',
|
||||
resource: 'CarplaceHomepage',
|
||||
after: toAuditJson(saved),
|
||||
ipAddress: ip,
|
||||
userAgent: undefined,
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('analytics.service', () => {
|
||||
},
|
||||
] as never)
|
||||
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
|
||||
{ source: 'MARKETPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
|
||||
{ source: 'CARPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
|
||||
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
|
||||
] as never)
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||
@@ -115,7 +115,7 @@ describe('analytics.service', () => {
|
||||
totalAmount: 1200,
|
||||
})])
|
||||
expect(result.sourceBreakdown).toEqual([
|
||||
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
|
||||
{ source: 'CARPLACE', count: 3, revenue: 3600 },
|
||||
{ source: 'DIRECT', count: 2, revenue: 0 },
|
||||
])
|
||||
expect(result.subscription).toEqual({
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentVehicleWithAvailability } from './storefront.presenter'
|
||||
import { presentVehicleWithAvailability } from './carplace.presenter'
|
||||
|
||||
describe('storefront.presenter', () => {
|
||||
describe('carplace.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
-11
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
import * as repo from './storefront.repo'
|
||||
import * as repo from './carplace.repo'
|
||||
|
||||
describe('storefront.repo public query and write boundaries', () => {
|
||||
describe('carplace.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,20 +33,20 @@ describe('storefront.repo public query and write boundaries', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('lists storefront cities only from active listed companies with a public city', async () => {
|
||||
it('lists carplace 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 } },
|
||||
brand: { isListedOnCarplace: 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')
|
||||
it('requires published vehicles and an active company when loading carplace vehicle details', async () => {
|
||||
await repo.findVehicleForCarplace('vehicle_1', 'atlas')
|
||||
|
||||
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -58,13 +58,13 @@ describe('storefront.repo public query and write boundaries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('patches storefront customer address fields without deleting existing address metadata', async () => {
|
||||
it('patches carplace 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', {
|
||||
await repo.upsertCarplaceCustomer('company_1', {
|
||||
email: 'renter@example.test',
|
||||
firstName: 'Nora',
|
||||
lastName: 'Renter',
|
||||
@@ -86,11 +86,11 @@ describe('storefront.repo public query and write boundaries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('creates storefront reservations as draft storefront-sourced records', async () => {
|
||||
it('creates carplace reservations as draft carplace-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({
|
||||
await repo.createCarplaceReservation({
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
@@ -108,7 +108,7 @@ describe('storefront.repo public query and write boundaries', () => {
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
status: 'DRAFT',
|
||||
}),
|
||||
})
|
||||
+16
-16
@@ -13,7 +13,7 @@ export async function findCitiesFromCompanies() {
|
||||
return prisma.company.findMany({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
||||
brand: { isListedOnCarplace: true, publicCity: { not: null } },
|
||||
},
|
||||
select: { brand: { select: { publicCity: true } } },
|
||||
})
|
||||
@@ -23,7 +23,7 @@ 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 } },
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, carplaceRating: true, primaryColor: true } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
|
||||
},
|
||||
skip,
|
||||
@@ -35,31 +35,31 @@ 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 } } } },
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, carplaceRating: true, primaryColor: true } } } },
|
||||
},
|
||||
orderBy: { dailyRate: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForStorefront(vehicleId: string, companySlug: string) {
|
||||
export async function findVehicleForCarplace(vehicleId: string, companySlug: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
|
||||
where: { id: vehicleId, isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: companySlug }, { brand: { subdomain: companySlug } }] } },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForStorefrontById(vehicleId: string) {
|
||||
export async function findVehicleForCarplaceById(vehicleId: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: {
|
||||
id: vehicleId,
|
||||
isPublished: true,
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
|
||||
},
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
export async function upsertCarplaceCustomer(companyId: string, data: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
@@ -115,17 +115,17 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
return prisma.customer.update({ where: { id: existing.id }, data: payload })
|
||||
}
|
||||
|
||||
export async function createStorefrontReservation(data: {
|
||||
export async function createCarplaceReservation(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' },
|
||||
data: { ...data, source: 'CARPLACE', status: 'DRAFT' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createStorefrontFunnelEvent(data: {
|
||||
export async function createCarplaceFunnelEvent(data: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
@@ -138,12 +138,12 @@ export async function createStorefrontFunnelEvent(data: {
|
||||
where: {
|
||||
id: data.vehicleId,
|
||||
isPublished: true,
|
||||
company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: data.companySlug }, { brand: { subdomain: data.companySlug } }] },
|
||||
},
|
||||
select: { companyId: true },
|
||||
})
|
||||
|
||||
return prisma.marketplaceFunnelEvent.create({
|
||||
return prisma.carplaceFunnelEvent.create({
|
||||
data: {
|
||||
eventName: data.eventName,
|
||||
companyId: vehicle?.companyId ?? null,
|
||||
@@ -159,7 +159,7 @@ export async function createStorefrontFunnelEvent(data: {
|
||||
|
||||
export async function findCompanyPage(slug: string) {
|
||||
return prisma.company.findFirst({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] },
|
||||
include: {
|
||||
brand: true,
|
||||
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
|
||||
@@ -169,7 +169,7 @@ export async function findCompanyPage(slug: string) {
|
||||
}
|
||||
|
||||
export async function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
return prisma.company.findFirstOrThrow({ where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] } })
|
||||
}
|
||||
|
||||
export async function findCompanyReviews(companyId: string) {
|
||||
@@ -189,7 +189,7 @@ export async function findCompanyVehicles(companyId: string) {
|
||||
}
|
||||
|
||||
export async function findVehicleById(slug: string, vehicleId: string) {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] } })
|
||||
return prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
||||
include: { company: { include: { brand: true } } },
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { Router } from 'express'
|
||||
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { created, ok } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import * as service from './carplace.service'
|
||||
import {
|
||||
carplaceQuoteSchema,
|
||||
companiesQuerySchema,
|
||||
carplaceFunnelEventSchema,
|
||||
offerCodeParamSchema,
|
||||
reviewBodySchema,
|
||||
reviewTokenSchema,
|
||||
paginationSchema,
|
||||
searchSchema,
|
||||
slugParamSchema,
|
||||
carplaceReservationSchema,
|
||||
vehicleAvailabilityQuerySchema,
|
||||
vehicleParamSchema,
|
||||
} from './carplace.schemas'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
const idempotencyCache = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
|
||||
const IDEMPOTENCY_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
function cleanupIdempotencyCache(now = Date.now()) {
|
||||
for (const [key, value] of idempotencyCache) if (value.expiresAt <= now) idempotencyCache.delete(key)
|
||||
}
|
||||
|
||||
router.get('/home', async (_req, res, next) => {
|
||||
try {
|
||||
const [cities, offers, companies, search] = await Promise.all([
|
||||
service.getCities(),
|
||||
service.getPublicOffers(),
|
||||
service.getListedCompanies({ page: 1, pageSize: 8 }),
|
||||
service.searchVehiclesPage({ page: 1, pageSize: 9 }),
|
||||
])
|
||||
ok(res, { cities, offers: offers.slice(0, 6), companies, vehicles: search.items })
|
||||
} catch (error) {
|
||||
if (isDatabaseUnavailableError(error)) return ok(res, { cities: [], offers: [], companies: [], vehicles: [] })
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cities', async (_req, res, next) => {
|
||||
try { ok(res, await service.getCities()) }
|
||||
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||
})
|
||||
|
||||
router.get('/offers', async (_req, res, next) => {
|
||||
try { ok(res, await service.getPublicOffers()) }
|
||||
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||
})
|
||||
|
||||
router.get('/companies', async (req, res, next) => {
|
||||
try {
|
||||
const filters = parseQuery(companiesQuerySchema, req)
|
||||
const pagination = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.getListedCompanies({ ...filters, ...pagination }))
|
||||
} catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||
})
|
||||
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const filters = parseQuery(searchSchema, req)
|
||||
const pagination = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.searchVehiclesPage({ ...filters, ...pagination }))
|
||||
} catch (error) {
|
||||
if (isDatabaseUnavailableError(error)) return ok(res, { items: [], pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } })
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/quotes', async (req, res, next) => {
|
||||
try { ok(res, await service.createCarplaceQuote(parseBody(carplaceQuoteSchema, req))) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
cleanupIdempotencyCache()
|
||||
const body = parseBody(carplaceReservationSchema, req)
|
||||
const key = body.idempotencyKey ?? req.header('Idempotency-Key')?.trim()
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex')
|
||||
if (key) {
|
||||
const cached = idempotencyCache.get(key)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
if (cached.fingerprint !== fingerprint) return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
|
||||
return ok(res, cached.result)
|
||||
}
|
||||
}
|
||||
const result = await service.createCarplaceReservation(body)
|
||||
if (key) idempotencyCache.set(key, { expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, fingerprint, result })
|
||||
created(res, result)
|
||||
} catch (error) {
|
||||
if (isDatabaseUnavailableError(error)) return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/events', async (req, res, next) => {
|
||||
try { ok(res, await service.trackCarplaceFunnelEvent(parseBody(carplaceFunnelEventSchema, req), req.renterId)) }
|
||||
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, { success: false }); next(error) }
|
||||
})
|
||||
|
||||
router.get('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
ok(res, await service.getReviewContext(token))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.post('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
created(res, await service.submitReview(token, parseBody(reviewBodySchema, req)))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseParams(offerCodeParamSchema, req)
|
||||
ok(res, await service.validateOfferCode(code))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug', async (req, res, next) => {
|
||||
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyPage(slug)) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug/reviews', async (req, res, next) => {
|
||||
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyReviews(slug)) }
|
||||
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||
})
|
||||
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyVehicles(slug)) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyOffers(slug)) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try { const { slug, id } = parseParams(vehicleParamSchema, req); ok(res, await service.getVehicleDetail(slug, id)) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
const range = parseQuery(vehicleAvailabilityQuerySchema, req)
|
||||
ok(res, await service.getCarplaceVehicleAvailability(slug, id, range))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
export default router
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { storefrontReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './storefront.schemas'
|
||||
import { carplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './carplace.schemas'
|
||||
|
||||
describe('storefront.schemas', () => {
|
||||
describe('carplace.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('storefront.schemas', () => {
|
||||
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
|
||||
})
|
||||
|
||||
it('defaults storefront reservation language while keeping date and email validation strict', () => {
|
||||
const parsed = storefrontReservationSchema.parse({
|
||||
it('defaults carplace reservation language while keeping date and email validation strict', () => {
|
||||
const parsed = carplaceReservationSchema.parse({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
@@ -30,8 +30,8 @@ describe('storefront.schemas', () => {
|
||||
})
|
||||
|
||||
expect(parsed.language).toBe('fr')
|
||||
expect(() => storefrontReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||
expect(() => storefrontReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||
expect(() => carplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||
expect(() => carplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||
})
|
||||
|
||||
it('validates review ratings as bounded integers', () => {
|
||||
+15
-2
@@ -29,7 +29,17 @@ export const vehicleAvailabilityQuerySchema = z.object({
|
||||
endDate: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const storefrontReservationSchema = z.object({
|
||||
|
||||
export const carplaceQuoteSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
returnLocation: z.string().trim().max(100).optional(),
|
||||
promoCode: z.string().trim().max(100).optional(),
|
||||
})
|
||||
|
||||
export const carplaceReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100).optional(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
@@ -37,6 +47,7 @@ export const storefrontReservationSchema = z.object({
|
||||
email: z.string().email(),
|
||||
// Contact info — collected at reservation time
|
||||
phone: z.string().min(1).max(30).optional(),
|
||||
driverAge: z.coerce.number().int().min(18).max(100).optional(),
|
||||
// Identity & license — optional at reservation, collected at pickup
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
@@ -52,11 +63,13 @@ export const storefrontReservationSchema = z.object({
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
returnLocation: z.string().trim().max(100).optional(),
|
||||
promoCode: z.string().trim().max(100).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
idempotencyKey: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
export const marketplaceFunnelEventSchema = z.object({
|
||||
export const carplaceFunnelEventSchema = z.object({
|
||||
eventName: z.enum([
|
||||
'booking_form_viewed',
|
||||
'trip_dates_selected',
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./storefront.repo', () => ({
|
||||
vi.mock('./carplace.repo', () => ({
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
@@ -12,16 +12,16 @@ vi.mock('./storefront.repo', () => ({
|
||||
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() },
|
||||
carplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
||||
}))
|
||||
|
||||
import * as repo from './storefront.repo'
|
||||
import * as repo from './carplace.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './storefront.service'
|
||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './carplace.service'
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('storefront.service public page edges', () => {
|
||||
describe('carplace.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({
|
||||
+194
-45
@@ -1,8 +1,8 @@
|
||||
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'
|
||||
import { carplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './carplace.repo'
|
||||
|
||||
function normalizeLocation(value?: string | null) {
|
||||
const normalized = value?.trim()
|
||||
@@ -21,6 +21,28 @@ function includesLocation(values: string[], target: string) {
|
||||
return values.some((value) => value.toLocaleLowerCase() === key)
|
||||
}
|
||||
|
||||
function calculateOfferDiscount(
|
||||
offer: { type?: string; discountValue?: number; specialRate?: number | null; maxRedemptions?: number | null; redemptionCount?: number | null },
|
||||
baseSubtotal: number,
|
||||
dailyRate: number,
|
||||
rentalDays: number,
|
||||
) {
|
||||
if (offer.maxRedemptions != null && (offer.redemptionCount ?? 0) >= offer.maxRedemptions) {
|
||||
throw new AppError('Offer code has reached its redemption limit', 409, 'offer_exhausted')
|
||||
}
|
||||
|
||||
const value = Number(offer.discountValue ?? 0)
|
||||
let discount = 0
|
||||
if (offer.type === 'PERCENTAGE') discount = Math.round(baseSubtotal * (value / 100))
|
||||
else if (offer.type === 'FIXED_AMOUNT') discount = value
|
||||
else if (offer.type === 'FREE_DAY') discount = dailyRate * value
|
||||
else if (offer.type === 'SPECIAL_RATE') {
|
||||
const specialRate = Number(offer.specialRate ?? value)
|
||||
discount = Math.max(0, baseSubtotal - (specialRate * rentalDays))
|
||||
}
|
||||
return Math.min(baseSubtotal, Math.max(0, Math.round(discount)))
|
||||
}
|
||||
|
||||
function matchesVehicleLocationRules(
|
||||
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
|
||||
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
|
||||
@@ -65,7 +87,7 @@ export async function getListedCompanies(params: {
|
||||
}) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
|
||||
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: 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() } } }
|
||||
@@ -79,53 +101,106 @@ export async function searchVehicles(params: {
|
||||
maxPrice?: number; transmission?: string; make?: string; model?: string
|
||||
page?: number; pageSize?: number
|
||||
}) {
|
||||
const page = params.page ?? 1
|
||||
const result = await searchVehiclesPage(params)
|
||||
return result.items
|
||||
}
|
||||
|
||||
export async function searchVehiclesPage(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 } },
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
|
||||
}
|
||||
if (params.category) where.category = params.category
|
||||
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.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' } } }
|
||||
where.company = { ...where.company, brand: { isListedOnCarplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
|
||||
}
|
||||
|
||||
let range: { startDate: Date; endDate: Date } | undefined
|
||||
if (params.startDate || params.endDate) {
|
||||
if (!params.startDate || !params.endDate) throw new AppError('Both start and end dates are required', 400, 'invalid_dates')
|
||||
const startDate = new Date(params.startDate)
|
||||
const endDate = new Date(params.endDate)
|
||||
if (!Number.isFinite(startDate.getTime()) || !Number.isFinite(endDate.getTime()) || endDate <= startDate) {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
range = { startDate, endDate }
|
||||
}
|
||||
|
||||
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 availabilityByVehicle = new Map<string, any>()
|
||||
|
||||
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)
|
||||
let availableVehicles = locationFiltered
|
||||
if (range) {
|
||||
const availability = await Promise.all(
|
||||
locationFiltered.map(async (vehicle: any) => {
|
||||
const result = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range })
|
||||
availabilityByVehicle.set(vehicle.id, result)
|
||||
return result.available ? vehicle : null
|
||||
}),
|
||||
)
|
||||
availableVehicles = availability.filter((vehicle): vehicle is any => Boolean(vehicle))
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
const pageItems = availableVehicles.slice((page - 1) * pageSize, page * pageSize)
|
||||
const items = pageItems.map((vehicle: any) => {
|
||||
const availability = availabilityByVehicle.get(vehicle.id)
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability?.available ?? true,
|
||||
availabilityStatus: availability?.status ?? 'AVAILABLE',
|
||||
nextAvailableAt: availability?.nextAvailableAt ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const countValues = (key: 'category' | 'transmission' | 'make') => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const vehicle of availableVehicles) {
|
||||
const value = typeof vehicle[key] === 'string' ? vehicle[key].trim() : ''
|
||||
if (value) counts.set(value, (counts.get(value) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(counts, ([value, count]) => ({ value, count })).sort((a, b) => a.value.localeCompare(b.value))
|
||||
}
|
||||
const prices = availableVehicles.map((vehicle) => Number(vehicle.dailyRate)).filter(Number.isFinite)
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems: availableVehicles.length,
|
||||
totalPages: Math.ceil(availableVehicles.length / pageSize),
|
||||
},
|
||||
facets: {
|
||||
categories: countValues('category'),
|
||||
transmissions: countValues('transmission'),
|
||||
makes: countValues('make'),
|
||||
price: {
|
||||
min: prices.length ? Math.min(...prices) : null,
|
||||
max: prices.length ? Math.max(...prices) : null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStorefrontVehicleAvailability(
|
||||
export async function getCarplaceVehicleAvailability(
|
||||
slug: string,
|
||||
vehicleId: string,
|
||||
params: { startDate: string; endDate: string },
|
||||
) {
|
||||
const vehicle = await repo.findVehicleForStorefront(vehicleId, slug)
|
||||
const vehicle = await repo.findVehicleForCarplace(vehicleId, slug)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
@@ -148,20 +223,85 @@ export async function getStorefrontVehicleAvailability(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCarplaceQuote(body: {
|
||||
vehicleId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
pickupLocation?: string
|
||||
returnLocation?: string
|
||||
promoCode?: string
|
||||
}) {
|
||||
const startDate = new Date(body.startDate)
|
||||
const endDate = new Date(body.endDate)
|
||||
if (!Number.isFinite(startDate.getTime()) || !Number.isFinite(endDate.getTime()) || endDate <= startDate) {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForCarplaceById(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 },
|
||||
})
|
||||
const rentalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const baseSubtotal = vehicle.dailyRate * rentalDays
|
||||
let discountAmount = 0
|
||||
const discounts: Array<{ label: string; amount: number }> = []
|
||||
|
||||
if (body.promoCode) {
|
||||
const offer = await repo.findOfferByCode(body.promoCode.trim())
|
||||
if (!offer || offer.companyId !== vehicle.companyId) throw new AppError('Offer code is invalid or expired', 400, 'offer_invalid')
|
||||
discountAmount = calculateOfferDiscount(offer, baseSubtotal, vehicle.dailyRate, rentalDays)
|
||||
discounts.push({ label: offer.title, amount: discountAmount })
|
||||
}
|
||||
|
||||
return {
|
||||
available: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
currency: 'MAD' as const,
|
||||
rentalDays,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
baseSubtotal,
|
||||
discounts,
|
||||
fees: [],
|
||||
taxes: [],
|
||||
estimatedTotal: Math.max(0, baseSubtotal - discountAmount),
|
||||
securityDeposit: null,
|
||||
finalPriceRequiresCompanyConfirmation: true as const,
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
export async function createCarplaceReservation(body: {
|
||||
vehicleId: string; companySlug?: string; firstName: string; lastName: string
|
||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
||||
email: string; phone?: string; driverAge?: number; 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
|
||||
pickupLocation?: string; returnLocation?: string; promoCode?: string; notes?: string; language?: string
|
||||
}) {
|
||||
const startDate = new Date(body.startDate)
|
||||
const endDate = new Date(body.endDate)
|
||||
@@ -170,7 +310,7 @@ export async function createStorefrontReservation(body: {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForStorefrontById(body.vehicleId)
|
||||
const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||
@@ -198,7 +338,7 @@ export async function createStorefrontReservation(body: {
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
|
||||
const customer = await repo.upsertCarplaceCustomer(vehicle.companyId, {
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
@@ -215,10 +355,19 @@ export async function createStorefrontReservation(body: {
|
||||
internationalLicenseNumber: body.internationalLicenseNumber,
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const baseSubtotal = vehicle.dailyRate * totalDays
|
||||
let discountAmount = 0
|
||||
if (body.promoCode) {
|
||||
const offer = await repo.findOfferByCode(body.promoCode.trim())
|
||||
if (!offer || offer.companyId !== vehicle.companyId) throw new AppError('Offer code is invalid or expired', 400, 'offer_invalid')
|
||||
discountAmount = calculateOfferDiscount(offer, baseSubtotal, vehicle.dailyRate, totalDays)
|
||||
}
|
||||
const totalAmount = Math.max(0, baseSubtotal - discountAmount)
|
||||
|
||||
const reservation = await repo.createStorefrontReservation({
|
||||
const reservationNotes = [body.notes?.trim(), body.driverAge ? `Driver age: ${body.driverAge}` : null].filter(Boolean).join('\n').slice(0, 500) || undefined
|
||||
|
||||
const reservation = await repo.createCarplaceReservation({
|
||||
companyId: vehicle.companyId,
|
||||
vehicleId: body.vehicleId,
|
||||
customerId: customer.id,
|
||||
@@ -229,7 +378,7 @@ export async function createStorefrontReservation(body: {
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount,
|
||||
notes: body.notes,
|
||||
notes: reservationNotes,
|
||||
bookingReference: generateBookingReference(),
|
||||
})
|
||||
|
||||
@@ -245,14 +394,14 @@ export async function createStorefrontReservation(body: {
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: storefrontReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: storefrontReservationEmail.html(emailOpts, lang),
|
||||
text: storefrontReservationEmail.text(emailOpts, lang),
|
||||
subject: carplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: carplaceReservationEmail.html(emailOpts, lang),
|
||||
text: carplaceReservationEmail.text(emailOpts, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
sendNotification({
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'New Storefront Reservation Request',
|
||||
title: 'New Carplace 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 +419,7 @@ export async function createStorefrontReservation(body: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackStorefrontFunnelEvent(body: {
|
||||
export async function trackCarplaceFunnelEvent(body: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
@@ -278,7 +427,7 @@ export async function trackStorefrontFunnelEvent(body: {
|
||||
path?: string
|
||||
metadata?: Record<string, string | number | boolean | null>
|
||||
}, renterId?: string) {
|
||||
await repo.createStorefrontFunnelEvent({
|
||||
await repo.createCarplaceFunnelEvent({
|
||||
...body,
|
||||
renterId: renterId ?? null,
|
||||
})
|
||||
+108
-26
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
|
||||
|
||||
vi.mock('./storefront.repo', () => ({
|
||||
vi.mock('./carplace.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(),
|
||||
findVehicleForCarplace: vi.fn(),
|
||||
findVehicleForCarplaceById: vi.fn(),
|
||||
upsertCarplaceCustomer: vi.fn(),
|
||||
createCarplaceReservation: vi.fn(),
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
storefrontReservationEmail: {
|
||||
carplaceReservationEmail: {
|
||||
subject: vi.fn(() => 'Subject'),
|
||||
html: vi.fn(() => '<html>'),
|
||||
text: vi.fn(() => 'text'),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as repo from './storefront.repo'
|
||||
import * as repo from './carplace.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import {
|
||||
getCities, searchVehicles, createStorefrontReservation,
|
||||
getCities, searchVehicles, createCarplaceQuote, createCarplaceReservation,
|
||||
getReviewContext, submitReview, validateOfferCode,
|
||||
} from './storefront.service'
|
||||
} from './carplace.service'
|
||||
|
||||
const SLUG = 'test-company'
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('getCities', () => {
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('searchVehicles', () => {
|
||||
it('returns vehicles enriched with availability data', async () => {
|
||||
it('returns published vehicles with an available Carplace status when no dates are selected', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('searchVehicles', () => {
|
||||
expect(result[0].availabilityStatus).toBe('AVAILABLE')
|
||||
})
|
||||
|
||||
it('passes date range to availability service when provided', async () => {
|
||||
it('passes the date range to availability and removes unavailable vehicles before pagination', 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' })
|
||||
|
||||
@@ -101,7 +101,33 @@ describe('searchVehicles', () => {
|
||||
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)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('paginates after date-specific availability filtering', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([
|
||||
makeVehicle({ id: 'v-1' }),
|
||||
makeVehicle({ id: 'v-2' }),
|
||||
makeVehicle({ id: 'v-3' }),
|
||||
] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockImplementation(async (vehicleId) => ({
|
||||
available: vehicleId !== 'v-1',
|
||||
status: vehicleId === 'v-1' ? 'RESERVED' : 'AVAILABLE',
|
||||
nextAvailableAt: null,
|
||||
blockingReason: vehicleId === 'v-1' ? 'RESERVATION' : null,
|
||||
}) as any)
|
||||
|
||||
const result = await searchVehicles({
|
||||
startDate: '2026-07-10T10:00:00.000Z',
|
||||
endDate: '2026-07-12T10:00:00.000Z',
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('v-2')
|
||||
})
|
||||
|
||||
it('filters out vehicles that do not support different drop-off when requested', async () => {
|
||||
@@ -119,7 +145,49 @@ describe('searchVehicles', () => {
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createStorefrontReservation', () => {
|
||||
describe('createCarplaceQuote', () => {
|
||||
const body = {
|
||||
vehicleId: 'v-1',
|
||||
startDate: '2026-07-10T10:00:00.000Z',
|
||||
endDate: '2026-07-13T10:00:00.000Z',
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}
|
||||
|
||||
it('returns an availability-aware three-day estimate', async () => {
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await createCarplaceQuote(body)
|
||||
|
||||
expect(result.rentalDays).toBe(3)
|
||||
expect(result.baseSubtotal).toBe(30000)
|
||||
expect(result.estimatedTotal).toBe(30000)
|
||||
expect(result.finalPriceRequiresCompanyConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it('calculates fixed and free-day promotions instead of assuming every offer is a percentage', async () => {
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'co-1', type: 'FREE_DAY', discountValue: 1, title: 'One free day', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||
|
||||
const result = await createCarplaceQuote({ ...body, promoCode: 'FREE1' })
|
||||
|
||||
expect(result.discounts).toEqual([{ label: 'One free day', amount: 10000 }])
|
||||
expect(result.estimatedTotal).toBe(20000)
|
||||
})
|
||||
|
||||
it('rejects a promotion owned by a different rental company', async () => {
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'other-company', type: 'PERCENTAGE', discountValue: 10, title: 'Wrong company' } as any)
|
||||
|
||||
await expect(createCarplaceQuote({ ...body, promoCode: 'WRONG' })).rejects.toMatchObject({ error: 'offer_invalid' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createCarplaceReservation', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1', companySlug: SLUG,
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
@@ -137,49 +205,63 @@ describe('createStorefrontReservation', () => {
|
||||
}
|
||||
|
||||
it('creates reservation and returns reservationId', async () => {
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForCarplaceById).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)
|
||||
vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
||||
|
||||
const result = await createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
const result = await createCarplaceReservation({ ...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({
|
||||
expect(repo.upsertCarplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
}))
|
||||
expect(repo.createStorefrontReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}))
|
||||
})
|
||||
|
||||
it('throws NotFoundError when vehicle not found', async () => {
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(null)
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(null)
|
||||
|
||||
await expect(createStorefrontReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
await expect(createCarplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws AppError when vehicle is unavailable', async () => {
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
|
||||
await expect(createStorefrontReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
await expect(createCarplaceReservation(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)
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
|
||||
await expect(
|
||||
createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
).rejects.toMatchObject({ error: 'same_dropoff_required' })
|
||||
})
|
||||
|
||||
it('stores the same discounted total shown by the Carplace quote', async () => {
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'co-1', type: 'PERCENTAGE', discountValue: 10, title: 'Ten percent', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||
vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-2', bookingReference: 'BK-2026-PROMO1' } as any)
|
||||
|
||||
await createCarplaceReservation({ ...baseBody, promoCode: 'SAVE10' })
|
||||
|
||||
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
totalAmount: 27000,
|
||||
}))
|
||||
})
|
||||
|
||||
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' }),
|
||||
createCarplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ const fullBrand = {
|
||||
amanpaySecretKey: 'super-secret-key',
|
||||
paypalEmail: 'paypal@example.com',
|
||||
paypalMerchantId: 'paypal-merchant-xyz',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
}
|
||||
|
||||
describe('presentBrand', () => {
|
||||
@@ -84,7 +84,7 @@ describe('presentBrand', () => {
|
||||
primaryColor: '#1A56DB',
|
||||
defaultLocale: 'en',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const brandSchema = z.object({
|
||||
amanpaySecretKey: z.string().optional(),
|
||||
paypalEmail: optionalEmailField(),
|
||||
paypalMerchantId: z.string().optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
|
||||
@@ -38,7 +38,7 @@ const editableSettingsFeatures = [
|
||||
'settings.company_profile',
|
||||
'settings.public_contact',
|
||||
'settings.locale_currency',
|
||||
'settings.storefront_listing',
|
||||
'settings.carplace_listing',
|
||||
'settings.branding_basic',
|
||||
'settings.branding_custom',
|
||||
'settings.branding_hero',
|
||||
|
||||
@@ -16,7 +16,7 @@ const editableSettingsFeatures = [
|
||||
'settings.company_profile',
|
||||
'settings.public_contact',
|
||||
'settings.locale_currency',
|
||||
'settings.storefront_listing',
|
||||
'settings.carplace_listing',
|
||||
'settings.branding_basic',
|
||||
'settings.branding_custom',
|
||||
'settings.branding_hero',
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('settingsEntitlements', () => {
|
||||
expect(result.items).toHaveLength(7)
|
||||
expect(result.items.map((item) => item.sectionKey)).toEqual([
|
||||
'company',
|
||||
'storefront',
|
||||
'carplace',
|
||||
'payments',
|
||||
'rental-policies',
|
||||
'insurance',
|
||||
|
||||
@@ -7,7 +7,7 @@ export type SettingsFeatureKey =
|
||||
| 'settings.company_profile'
|
||||
| 'settings.public_contact'
|
||||
| 'settings.locale_currency'
|
||||
| 'settings.storefront_listing'
|
||||
| 'settings.carplace_listing'
|
||||
| 'settings.branding_basic'
|
||||
| 'settings.branding_custom'
|
||||
| 'settings.branding_hero'
|
||||
@@ -22,7 +22,7 @@ export type SettingsFeatureKey =
|
||||
|
||||
export type SettingsSectionKey =
|
||||
| 'company'
|
||||
| 'storefront'
|
||||
| 'carplace'
|
||||
| 'payments'
|
||||
| 'rental-policies'
|
||||
| 'insurance'
|
||||
@@ -51,7 +51,7 @@ const PRO_ONLY: Plan[] = ['PRO']
|
||||
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
|
||||
en: {
|
||||
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
|
||||
storefront: { label: 'Branding and Storefront', description: 'Control marketplace listing, logo, colors, and storefront media.' },
|
||||
carplace: { label: 'Branding and Carplace', description: 'Control Carplace listing, logo, colors, and media.' },
|
||||
payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' },
|
||||
'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
|
||||
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
|
||||
@@ -60,7 +60,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
|
||||
},
|
||||
fr: {
|
||||
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' },
|
||||
storefront: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
|
||||
carplace: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
|
||||
payments: { label: 'Méthodes de paiement', description: 'Configurez les prestataires de paiement des locataires.' },
|
||||
'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
|
||||
insurance: { label: 'Polices d’assurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
|
||||
@@ -69,7 +69,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
|
||||
},
|
||||
ar: {
|
||||
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
|
||||
storefront: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
|
||||
carplace: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
|
||||
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' },
|
||||
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
|
||||
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
|
||||
@@ -87,7 +87,7 @@ export const SETTINGS_MENU_CATALOG: Array<{
|
||||
requiredPlan: Plan | null
|
||||
}> = [
|
||||
{ menuKey: 'company', sectionKey: 'company', iconKey: 'building', sortOrder: 10, plans: ALL_PLANS, requiredPlan: null },
|
||||
{ menuKey: 'storefront', sectionKey: 'storefront', iconKey: 'palette', sortOrder: 20, plans: ALL_PLANS, requiredPlan: null },
|
||||
{ menuKey: 'carplace', sectionKey: 'carplace', iconKey: 'palette', sortOrder: 20, plans: ALL_PLANS, requiredPlan: null },
|
||||
{ menuKey: 'payments', sectionKey: 'payments', iconKey: 'credit-card', sortOrder: 30, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
{ menuKey: 'rental-policies', sectionKey: 'rental-policies', iconKey: 'file-text', sortOrder: 40, plans: ALL_PLANS, requiredPlan: null },
|
||||
{ menuKey: 'insurance', sectionKey: 'insurance', iconKey: 'shield', sortOrder: 50, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
@@ -99,7 +99,7 @@ const FEATURE_PLANS: Record<SettingsFeatureKey, { plans: Plan[]; requiredPlan: P
|
||||
'settings.company_profile': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.public_contact': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.locale_currency': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.storefront_listing': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.carplace_listing': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.branding_basic': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.branding_custom': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
'settings.branding_hero': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
@@ -149,7 +149,7 @@ function statusAllowsEditing(status: SubscriptionStatus, featureKey: SettingsFea
|
||||
'settings.company_profile',
|
||||
'settings.public_contact',
|
||||
'settings.locale_currency',
|
||||
'settings.storefront_listing',
|
||||
'settings.carplace_listing',
|
||||
'settings.branding_basic',
|
||||
'settings.rental_policies_basic',
|
||||
].includes(featureKey)
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function confirmReservation(id: string, companyId: string) {
|
||||
},
|
||||
}).catch(() => null)
|
||||
|
||||
if (reservation.source === 'MARKETPLACE') {
|
||||
if (reservation.source === 'CARPLACE') {
|
||||
const progress = buildBookingRequestProgress({
|
||||
source: reservation.source,
|
||||
status: 'CONFIRMED',
|
||||
@@ -193,7 +193,7 @@ export async function closeReservation(id: string, companyId: string, closedBy:
|
||||
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
|
||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
|
||||
const reviewUrl = `${process.env.STOREFRONT_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: customer.email,
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('reservation.presenter boundary behavior', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
@@ -84,7 +84,7 @@ describe('reservation.presenter boundary behavior', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('serializeReservationForDashboard', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation-1',
|
||||
status: 'CONFIRMED',
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
|
||||
@@ -68,8 +68,8 @@ export function buildBookingRequestProgress(reservation: {
|
||||
}
|
||||
|
||||
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
|
||||
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0
|
||||
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
|
||||
const documentsRequired = reservation.source === 'CARPLACE' && companyConfirmed && documentsMissing.length > 0
|
||||
const paymentRequired = reservation.source === 'CARPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
|
||||
|
||||
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
|
||||
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('review.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
|
||||
process.env.STOREFRONT_URL = 'https://market.example'
|
||||
process.env.CARPLACE_URL = 'https://market.example'
|
||||
})
|
||||
|
||||
it('returns reviews with rating filters and pagination metadata', async () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function sendReviewReminder(id: string, companyId: string) {
|
||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||
const vehicle = reservation.vehicle
|
||||
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
|
||||
const reviewUrl = `${process.env.STOREFRONT_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: customer.email,
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('site.presenter', () => {
|
||||
paymentMethodsEnabled: ['PAYPAL'],
|
||||
defaultLocale: 'fr',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('site.presenter', () => {
|
||||
paymentMethodsEnabled: ['PAYPAL'],
|
||||
defaultLocale: 'fr',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
},
|
||||
})
|
||||
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
|
||||
|
||||
@@ -30,8 +30,8 @@ export function presentBrand(company: {
|
||||
paypalEmail: brand.paypalEmail,
|
||||
paypalMerchantId: brand.paypalMerchantId,
|
||||
paymentMethodsEnabled: brand.paymentMethodsEnabled,
|
||||
isListedOnMarketplace: brand.isListedOnMarketplace,
|
||||
marketplaceRating: brand.marketplaceRating,
|
||||
isListedOnCarplace: brand.isListedOnCarplace,
|
||||
carplaceRating: brand.carplaceRating,
|
||||
homePageConfig: brand.homePageConfig,
|
||||
menuConfig: brand.menuConfig,
|
||||
} : null,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const bookSchema = z.object({
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
|
||||
source: z.enum(['PUBLIC_SITE', 'CARPLACE']).default('PUBLIC_SITE'),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(z.object({
|
||||
firstName: z.string().min(1),
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
|
||||
planFeature: { findMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../services/platformContentService', () => ({ getStorefrontHomepageContent: vi.fn() }))
|
||||
vi.mock('../../services/platformContentService', () => ({ getCarplaceHomepageContent: vi.fn() }))
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
|
||||
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
|
||||
|
||||
@@ -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 { getStorefrontHomepageContent } from '../../services/platformContentService'
|
||||
import { getCarplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
@@ -30,7 +30,7 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
||||
allowedHosts.add('127.0.0.1:3000')
|
||||
allowedHosts.add('127.0.0.1:4000')
|
||||
}
|
||||
for (const value of [process.env.STOREFRONT_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_STOREFRONT_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||
for (const value of [process.env.CARPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_CARPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||
if (!value) continue
|
||||
try { allowedHosts.add(new URL(value).host) } catch {}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ function generateBookingReference() {
|
||||
}
|
||||
|
||||
export async function getPlatformHomepage() {
|
||||
return getStorefrontHomepageContent()
|
||||
return getCarplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function getPlatformPricing() {
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../services/platformContentService', () => ({
|
||||
getStorefrontHomepageContent: vi.fn(),
|
||||
getCarplaceHomepageContent: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as repo from './site.repo'
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
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
|
||||
@@ -49,7 +49,7 @@ describe('financialReportService', () => {
|
||||
pricingRulesTotal: 10,
|
||||
depositAmount: 500,
|
||||
totalAmount: 640,
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
startDate: new Date('2026-06-18T10:00:00.000Z'),
|
||||
endDate: new Date('2026-06-20T10:00:00.000Z'),
|
||||
vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' },
|
||||
|
||||
@@ -27,15 +27,15 @@ describe('platformContentService', () => {
|
||||
it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
|
||||
const service = await importFreshService()
|
||||
|
||||
const first = await service.getStorefrontHomepageContent()
|
||||
const first = await service.getCarplaceHomepageContent()
|
||||
first.en.heroTitle = 'Mutated by test'
|
||||
|
||||
const second = await service.getStorefrontHomepageContent()
|
||||
const second = await service.getCarplaceHomepageContent()
|
||||
expect(second.en.sections).toContain('hero')
|
||||
expect(second.en.heroTitle).not.toBe('Mutated by test')
|
||||
})
|
||||
|
||||
it('saves only the storefront homepage field while preserving unrelated platform content fields', async () => {
|
||||
it('saves only the Carplace homepage field while preserving unrelated platform content fields', async () => {
|
||||
const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
|
||||
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
|
||||
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
|
||||
@@ -43,13 +43,13 @@ describe('platformContentService', () => {
|
||||
})
|
||||
|
||||
const service = await importFreshService()
|
||||
const homepage = await service.getStorefrontHomepageContent()
|
||||
homepage.en.heroTitle = 'A better storefront title'
|
||||
const homepage = await service.getCarplaceHomepageContent()
|
||||
homepage.en.heroTitle = 'A better Carplace title'
|
||||
|
||||
await expect(service.saveStorefrontHomepageContent(homepage)).resolves.toBe(homepage)
|
||||
await expect(service.saveCarplaceHomepageContent(homepage)).resolves.toBe(homepage)
|
||||
|
||||
const raw = JSON.parse(await readFile(dataPath, 'utf8'))
|
||||
expect(raw.otherSetting).toEqual({ keep: true })
|
||||
expect(raw.storefrontHomepage.en.heroTitle).toBe('A better storefront title')
|
||||
expect(raw.carplaceHomepage.en.heroTitle).toBe('A better Carplace title')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { cloneStorefrontHomepageContent, type StorefrontHomepageConfig } from '@rentaldrivego/types'
|
||||
import {
|
||||
cloneCarplaceHomepageContent,
|
||||
type CarplaceHomepageConfig,
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
function resolveContentPath() {
|
||||
const cwd = process.cwd()
|
||||
@@ -12,7 +15,7 @@ function resolveContentPath() {
|
||||
const platformContentPath = resolveContentPath()
|
||||
|
||||
type PlatformContentFile = {
|
||||
storefrontHomepage?: StorefrontHomepageConfig
|
||||
carplaceHomepage?: CarplaceHomepageConfig
|
||||
}
|
||||
|
||||
async function readPlatformContentFile(): Promise<PlatformContentFile> {
|
||||
@@ -29,16 +32,16 @@ async function writePlatformContentFile(content: PlatformContentFile) {
|
||||
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
|
||||
}
|
||||
|
||||
export async function getStorefrontHomepageContent() {
|
||||
export async function getCarplaceHomepageContent() {
|
||||
const content = await readPlatformContentFile()
|
||||
return content.storefrontHomepage ?? cloneStorefrontHomepageContent()
|
||||
return content.carplaceHomepage ?? cloneCarplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function saveStorefrontHomepageContent(homepage: StorefrontHomepageConfig) {
|
||||
export async function saveCarplaceHomepageContent(homepage: CarplaceHomepageConfig) {
|
||||
const content = await readPlatformContentFile()
|
||||
await writePlatformContentFile({
|
||||
...content,
|
||||
storefrontHomepage: homepage,
|
||||
carplaceHomepage: homepage,
|
||||
})
|
||||
return homepage
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('OpenAPI document boundary contract', () => {
|
||||
}),
|
||||
}))
|
||||
expect(openApiDocument.tags).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Storefront' }),
|
||||
expect.objectContaining({ name: 'Carplace' }),
|
||||
expect.objectContaining({ name: 'Subscriptions' }),
|
||||
expect.objectContaining({ name: 'Admin' }),
|
||||
]))
|
||||
|
||||
@@ -119,7 +119,7 @@ export const openApiDocument: JsonObject = {
|
||||
{ name: 'Companies', description: 'Company profile & settings' },
|
||||
{ name: 'Team', description: 'Employee management' },
|
||||
{ name: 'Subscriptions', description: 'Plan management & billing' },
|
||||
{ name: 'Storefront', description: 'Public booking portal' },
|
||||
{ name: 'Carplace', description: 'Public booking portal' },
|
||||
{ name: 'Site', description: 'White-label site API' },
|
||||
{ name: 'Admin', description: 'Platform administration' },
|
||||
],
|
||||
@@ -996,20 +996,20 @@ export const openApiDocument: JsonObject = {
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// MARKETPLACE (public — no auth required)
|
||||
// CARPLACE (public — no auth required)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
'/storefront/cities': {
|
||||
get: { tags: ['Storefront'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
|
||||
'/carplace/cities': {
|
||||
get: { tags: ['Carplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/companies': {
|
||||
get: { tags: ['Storefront'], summary: 'List companies', security: [], responses: { '200': ok } },
|
||||
'/carplace/companies': {
|
||||
get: { tags: ['Carplace'], summary: 'List companies', security: [], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/offers': {
|
||||
get: { tags: ['Storefront'], summary: 'Public offers', security: [], responses: { '200': ok } },
|
||||
'/carplace/offers': {
|
||||
get: { tags: ['Carplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/search': {
|
||||
'/carplace/search': {
|
||||
get: {
|
||||
tags: ['Storefront'],
|
||||
tags: ['Carplace'],
|
||||
summary: 'Search available vehicles',
|
||||
security: [],
|
||||
parameters: [
|
||||
@@ -1021,30 +1021,30 @@ export const openApiDocument: JsonObject = {
|
||||
responses: { '200': ok },
|
||||
},
|
||||
},
|
||||
'/storefront/reservations': {
|
||||
post: { tags: ['Storefront'], summary: 'Create storefront booking', security: [], responses: { '201': ok } },
|
||||
'/carplace/reservations': {
|
||||
post: { tags: ['Carplace'], summary: 'Create carplace booking', security: [], responses: { '201': ok } },
|
||||
},
|
||||
'/storefront/offers/{code}/validate': {
|
||||
post: { tags: ['Storefront'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/carplace/offers/{code}/validate': {
|
||||
post: { tags: ['Carplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/review/{token}': {
|
||||
get: { tags: ['Storefront'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
post: { tags: ['Storefront'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/carplace/review/{token}': {
|
||||
get: { tags: ['Carplace'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
post: { tags: ['Carplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/{slug}': {
|
||||
get: { tags: ['Storefront'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/carplace/{slug}': {
|
||||
get: { tags: ['Carplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/{slug}/reviews': {
|
||||
get: { tags: ['Storefront'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/carplace/{slug}/reviews': {
|
||||
get: { tags: ['Carplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/{slug}/vehicles': {
|
||||
get: { tags: ['Storefront'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/carplace/{slug}/vehicles': {
|
||||
get: { tags: ['Carplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/{slug}/vehicles/{id}': {
|
||||
get: { tags: ['Storefront'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
|
||||
'/carplace/{slug}/vehicles/{id}': {
|
||||
get: { tags: ['Carplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
|
||||
},
|
||||
'/storefront/{slug}/offers': {
|
||||
get: { tags: ['Storefront'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/carplace/{slug}/offers': {
|
||||
get: { tags: ['Carplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
+11
-11
@@ -40,12 +40,12 @@ vi.mock('../../modules/notifications/notification.service', () => ({
|
||||
getRenterPreferences: vi.fn(),
|
||||
setRenterPreferences: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/storefront/storefront.service', () => ({
|
||||
vi.mock('../../modules/carplace/carplace.service', () => ({
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
createStorefrontReservation: vi.fn(),
|
||||
createCarplaceReservation: vi.fn(),
|
||||
getCompanyPage: vi.fn(),
|
||||
getCompanyReviews: vi.fn(),
|
||||
getCompanyVehicles: vi.fn(),
|
||||
@@ -61,11 +61,11 @@ import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as employeeService from '../../modules/auth/auth.employee.service'
|
||||
import * as notificationService from '../../modules/notifications/notification.service'
|
||||
import * as storefrontService from '../../modules/storefront/storefront.service'
|
||||
import * as carplaceService from '../../modules/carplace/carplace.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('employee, notification, and storefront API validation contracts', () => {
|
||||
describe('employee, notification, and carplace API validation contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never)
|
||||
@@ -73,8 +73,8 @@ describe('employee, notification, and storefront API validation contracts', () =
|
||||
vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
|
||||
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never)
|
||||
vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never)
|
||||
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
|
||||
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
vi.mocked(carplaceService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
|
||||
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
})
|
||||
|
||||
it('normalizes employee login email before service execution', async () => {
|
||||
@@ -110,11 +110,11 @@ describe('employee, notification, and storefront API validation contracts', () =
|
||||
expect(notificationService.setPreferences).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults storefront search pagination at the public route boundary', async () => {
|
||||
const res = await request(app).get('/api/v1/storefront/search?city=Casablanca')
|
||||
it('defaults carplace search pagination at the public route boundary', async () => {
|
||||
const res = await request(app).get('/api/v1/carplace/search?city=Casablanca')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(storefrontService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(carplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
|
||||
city: 'Casablanca',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
@@ -122,7 +122,7 @@ describe('employee, notification, and storefront API validation contracts', () =
|
||||
})
|
||||
|
||||
it('rejects out-of-range public review ratings before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/storefront/review/token_123').send({
|
||||
const res = await request(app).post('/api/v1/carplace/review/token_123').send({
|
||||
overallRating: 6,
|
||||
vehicleRating: 5,
|
||||
serviceRating: 5,
|
||||
@@ -130,6 +130,6 @@ describe('employee, notification, and storefront API validation contracts', () =
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(storefrontService.submitReview).not.toHaveBeenCalled()
|
||||
expect(carplaceService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -30,12 +30,12 @@ vi.mock('../../modules/site/site.service', () => ({
|
||||
handleContact: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/storefront/storefront.service', () => ({
|
||||
vi.mock('../../modules/carplace/carplace.service', () => ({
|
||||
getPublicOffers: vi.fn(),
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
createStorefrontReservation: vi.fn(),
|
||||
createCarplaceReservation: vi.fn(),
|
||||
getReviewContext: vi.fn(),
|
||||
submitReview: vi.fn(),
|
||||
validateOfferCode: vi.fn(),
|
||||
@@ -49,7 +49,7 @@ vi.mock('../../modules/storefront/storefront.service', () => ({
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as siteService from '../../modules/site/site.service'
|
||||
import * as storefrontService from '../../modules/storefront/storefront.service'
|
||||
import * as carplaceService from '../../modules/carplace/carplace.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
@@ -59,9 +59,9 @@ describe('public validation API contracts', () => {
|
||||
vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
|
||||
vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never)
|
||||
vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never)
|
||||
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
|
||||
vi.mocked(storefrontService.createStorefrontReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
vi.mocked(carplaceService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
|
||||
vi.mocked(carplaceService.createCarplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
})
|
||||
|
||||
it('rejects malformed public availability checks before site service execution', async () => {
|
||||
@@ -120,11 +120,11 @@ describe('public validation API contracts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces storefront search pagination and price filters', async () => {
|
||||
const res = await request(app).get('/api/v1/storefront/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
|
||||
it('coerces carplace search pagination and price filters', async () => {
|
||||
const res = await request(app).get('/api/v1/carplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(storefrontService.searchVehicles).toHaveBeenCalledWith({
|
||||
expect(carplaceService.searchVehicles).toHaveBeenCalledWith({
|
||||
city: 'Rabat',
|
||||
maxPrice: 500,
|
||||
page: 2,
|
||||
@@ -132,15 +132,15 @@ describe('public validation API contracts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized storefront pagination before search execution', async () => {
|
||||
const res = await request(app).get('/api/v1/storefront/search?pageSize=500')
|
||||
it('rejects oversized carplace pagination before search execution', async () => {
|
||||
const res = await request(app).get('/api/v1/carplace/search?pageSize=500')
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(storefrontService.searchVehicles).not.toHaveBeenCalled()
|
||||
expect(carplaceService.searchVehicles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects storefront reservations with invalid public contact data before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/storefront/reservations').send({
|
||||
it('rejects carplace reservations with invalid public contact data before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/carplace/reservations').send({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
@@ -151,13 +151,13 @@ describe('public validation API contracts', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(storefrontService.createStorefrontReservation).not.toHaveBeenCalled()
|
||||
expect(carplaceService.createCarplaceReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid public review ratings before creating reviews', async () => {
|
||||
const res = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 6 })
|
||||
const res = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 6 })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(storefrontService.submitReview).not.toHaveBeenCalled()
|
||||
expect(carplaceService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('employee and storefront public boundary smoke', () => {
|
||||
describe('employee and carplace public boundary smoke', () => {
|
||||
it('rejects malformed employee login payloads without leaking internals', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/employee/login').send({ email: 'not-an-email', password: 'x' })
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('employee and storefront public boundary smoke', () => {
|
||||
})
|
||||
|
||||
it('rejects invalid public review payloads without executing private infrastructure', async () => {
|
||||
const res = await request(app).post('/api/v1/storefront/review/token_123').send({ overallRating: 0 })
|
||||
const res = await request(app).post('/api/v1/carplace/review/token_123').send({ overallRating: 0 })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(JSON.stringify(res.body)).not.toContain('Prisma')
|
||||
@@ -24,12 +24,12 @@ vi.mock('../../modules/site/site.service', () => ({
|
||||
capturePaypal: vi.fn(),
|
||||
handleContact: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/storefront/storefront.service', () => ({
|
||||
vi.mock('../../modules/carplace/carplace.service', () => ({
|
||||
getPublicOffers: vi.fn(),
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
createStorefrontReservation: vi.fn(),
|
||||
createCarplaceReservation: vi.fn(),
|
||||
getReviewContext: vi.fn(),
|
||||
submitReview: vi.fn(),
|
||||
validateOfferCode: vi.fn(),
|
||||
@@ -43,7 +43,7 @@ vi.mock('../../modules/storefront/storefront.service', () => ({
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as siteService from '../../modules/site/site.service'
|
||||
import * as storefrontService from '../../modules/storefront/storefront.service'
|
||||
import * as carplaceService from '../../modules/carplace/carplace.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
@@ -61,8 +61,8 @@ describe('public validation e2e smoke', () => {
|
||||
expect(payment.status).toBe(400)
|
||||
expect(siteService.initPayment).not.toHaveBeenCalled()
|
||||
|
||||
const review = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 0 })
|
||||
const review = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 0 })
|
||||
expect(review.status).toBe(400)
|
||||
expect(storefrontService.submitReview).not.toHaveBeenCalled()
|
||||
expect(carplaceService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('billing, storefront, and vehicle integration boundaries', () => {
|
||||
describe('billing, carplace, and vehicle integration boundaries', () => {
|
||||
it('documents the Batch-10 DB-backed regression targets for the real integration environment', () => {
|
||||
expect([
|
||||
'admin billing invoice lifecycle uses persisted line items and audit logs',
|
||||
'storefront company pages enrich vehicles with real availability records',
|
||||
'carplace company pages enrich vehicles with real availability records',
|
||||
'vehicle location settings persist through create/update/list flows',
|
||||
]).toHaveLength(3)
|
||||
})
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('employee, storefront, and notification integration boundaries', () => {
|
||||
describe('employee, carplace, and notification integration boundaries', () => {
|
||||
it('documents the Batch-17 DB-backed integration boundary', () => {
|
||||
expect({
|
||||
employeeAuth: 'password reset/login flows require generated Prisma and mail-provider test doubles',
|
||||
storefront: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
|
||||
carplace: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
|
||||
notifications: 'preference persistence requires notificationPreference composite indexes',
|
||||
}).toEqual(expect.objectContaining({
|
||||
employeeAuth: expect.stringContaining('Prisma'),
|
||||
storefront: expect.stringContaining('seeded'),
|
||||
carplace: expect.stringContaining('seeded'),
|
||||
notifications: expect.stringContaining('composite'),
|
||||
}))
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseBody, parseQuery } from '../../http/validate'
|
||||
import { storefrontReservationSchema, paginationSchema } from '../../modules/storefront/storefront.schemas'
|
||||
import { carplaceReservationSchema, paginationSchema } from '../../modules/carplace/carplace.schemas'
|
||||
import { paySchema } from '../../modules/site/site.schemas'
|
||||
|
||||
function reqWith(body: unknown, query: unknown = {}) {
|
||||
@@ -8,7 +8,7 @@ function reqWith(body: unknown, query: unknown = {}) {
|
||||
}
|
||||
|
||||
describe('public validation boundaries', () => {
|
||||
it('keeps storefront pagination coercion consistent with route parsing', () => {
|
||||
it('keeps carplace pagination coercion consistent with route parsing', () => {
|
||||
expect(parseQuery(paginationSchema, reqWith({}, { page: '4', pageSize: '25' }))).toEqual({
|
||||
page: 4,
|
||||
pageSize: 25,
|
||||
@@ -24,8 +24,8 @@ describe('public validation boundaries', () => {
|
||||
}))).toThrow('Invalid enum value')
|
||||
})
|
||||
|
||||
it('requires enough anonymous storefront reservation identity to create a booking request', () => {
|
||||
const parsed = parseBody(storefrontReservationSchema, reqWith({
|
||||
it('requires enough anonymous carplace reservation identity to create a booking request', () => {
|
||||
const parsed = parseBody(carplaceReservationSchema, reqWith({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
|
||||
Reference in New Issue
Block a user