add homepage management

This commit is contained in:
root
2026-05-07 00:34:29 -04:00
parent 750ae56a29
commit c4a45c8b21
33 changed files with 4228 additions and 418 deletions
+390 -1
View File
@@ -6,6 +6,8 @@ import { authenticator } from 'otplib'
import qrcode from 'qrcode'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../services/platformContentService'
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
const router = Router()
@@ -14,6 +16,244 @@ const permissionSchema = z.object({
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
})
const nullableString = z.union([z.string(), z.null()]).optional()
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
const nullableDate = z.union([
z.string().datetime(),
z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
z.null(),
]).optional()
const homePageConfigSchema = z.object({
heroTitle: nullableString,
heroDescription: nullableString,
viewOffersLabel: nullableString,
viewPricingLabel: nullableString,
contactCompanyLabel: nullableString,
activeOffersTitle: nullableString,
seeAllOffersLabel: nullableString,
noActiveOffersLabel: nullableString,
publishedVehiclesTitle: nullableString,
viewVehicleLabel: nullableString,
pricingEyebrow: nullableString,
pricingTitle: nullableString,
pricingDescription: nullableString,
showOffers: z.boolean().optional(),
showVehicles: z.boolean().optional(),
showPricing: z.boolean().optional(),
layout: z.object({
items: z.array(z.object({
id: z.string().min(1),
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
x: z.number().int().min(1),
y: z.number().int().min(1),
w: z.number().int().min(1),
h: z.number().int().min(1),
})),
}).optional(),
}).optional()
const menuConfigSchema = z.object({
aboutLabel: nullableString,
vehiclesLabel: nullableString,
offersLabel: nullableString,
pricingLabel: nullableString,
blogLabel: nullableString,
contactLabel: nullableString,
bookCarLabel: nullableString,
siteNavigationLabel: nullableString,
showAbout: z.boolean().optional(),
showVehicles: z.boolean().optional(),
showOffers: z.boolean().optional(),
showPricing: z.boolean().optional(),
showBlog: z.boolean().optional(),
showContact: z.boolean().optional(),
pageSections: z.object({
home: z.object({
hero: z.boolean().optional(),
offers: z.boolean().optional(),
vehicles: z.boolean().optional(),
pricing: z.boolean().optional(),
}).optional(),
about: z.object({
hero: z.boolean().optional(),
highlights: z.boolean().optional(),
details: z.boolean().optional(),
}).optional(),
offers: z.object({
header: z.boolean().optional(),
grid: z.boolean().optional(),
}).optional(),
pricing: z.object({
hero: z.boolean().optional(),
plans: z.boolean().optional(),
payments: z.boolean().optional(),
faq: z.boolean().optional(),
}).optional(),
vehicles: z.object({
header: z.boolean().optional(),
filters: z.boolean().optional(),
grid: z.boolean().optional(),
}).optional(),
vehicleDetail: z.object({
gallery: z.boolean().optional(),
summary: z.boolean().optional(),
}).optional(),
blog: z.object({
hero: z.boolean().optional(),
posts: z.boolean().optional(),
}).optional(),
contact: z.object({
content: z.boolean().optional(),
}).optional(),
booking: z.object({
content: z.boolean().optional(),
}).optional(),
bookingConfirmation: z.object({
hero: z.boolean().optional(),
summary: z.boolean().optional(),
actions: z.boolean().optional(),
}).optional(),
}).optional(),
}).optional()
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({
value: z.string().min(1),
label: z.string().min(1),
})
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({
title: z.string().min(1),
body: z.string().min(1),
})
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({
step: z.string().min(1),
title: z.string().min(1),
body: z.string().min(1),
})
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum([
'hero',
'surface',
'pillars',
'audiences',
'features',
'steps',
'closing',
])
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
sections: z.array(marketplaceSectionSchema).min(1),
heroKicker: z.string().min(1),
heroTitle: z.string().min(1),
heroBody: z.string().min(1),
startTrial: z.string().min(1),
exploreVehicles: z.string().min(1),
surfaceLabel: z.string().min(1),
surfaceTitle: z.string().min(1),
surfaceBody: z.string().min(1),
liveLabel: z.string().min(1),
trustedFleets: z.string().min(1),
brandedFlows: z.string().min(1),
multiTenant: z.string().min(1),
companyKicker: z.string().min(1),
companyTitle: z.string().min(1),
companyBody: z.string().min(1),
renterKicker: z.string().min(1),
renterTitle: z.string().min(1),
renterBody: z.string().min(1),
pillars: z.array(marketplacePillarSchema).length(3),
metrics: z.array(marketplaceMetricSchema).length(3),
featureLabel: z.string().min(1),
features: z.array(z.string().min(1)).min(1),
stepsTitle: z.string().min(1),
steps: z.array(marketplaceStepSchema).length(3),
stepLabel: z.string().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),
})
const marketplaceHomepageConfigSchema = z.object({
en: marketplaceHomepageContentSchema,
fr: marketplaceHomepageContentSchema,
ar: marketplaceHomepageContentSchema,
})
const adminCompanyUpdateSchema = z.object({
company: z.object({
name: z.string().min(1).optional(),
slug: z.string().min(1).optional(),
email: z.string().email().optional(),
phone: nullableString,
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
subscriptionPaymentRef: nullableString,
}).optional(),
subscription: z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
trialStartAt: nullableDate,
trialEndAt: nullableDate,
currentPeriodStart: nullableDate,
currentPeriodEnd: nullableDate,
cancelledAt: nullableDate,
cancelAtPeriodEnd: z.boolean().optional(),
}).optional(),
brand: z.object({
displayName: z.string().min(1).optional(),
tagline: nullableString,
subdomain: z.string().min(1).optional(),
customDomain: nullableString,
publicEmail: nullableEmail,
publicPhone: nullableString,
publicAddress: nullableString,
publicCity: nullableString,
publicCountry: nullableString,
websiteUrl: nullableUrl,
whatsappNumber: nullableString,
defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
isListedOnMarketplace: z.boolean().optional(),
homePageConfig: homePageConfigSchema,
menuConfig: menuConfigSchema,
}).optional(),
contractSettings: z.object({
legalName: nullableString,
registrationNumber: nullableString,
taxId: nullableString,
terms: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
lateFeePerHour: z.number().int().nullable().optional(),
taxRate: z.number().nullable().optional(),
signatureRequired: z.boolean().optional(),
showTax: z.boolean().optional(),
}).optional(),
accountingSettings: z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).optional(),
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
accountantEmail: nullableEmail,
accountantName: nullableString,
autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
}).optional(),
})
function parseOptionalDate(value?: string | null) {
if (!value) return null
return new Date(value)
}
function toAuditJson<T>(value: T) {
return JSON.parse(JSON.stringify(value))
}
function signAdminToken(adminId: string) {
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
}
@@ -97,11 +337,160 @@ router.get('/companies', requireAdminAuth, async (req, res, next) => {
router.get('/companies/:id', requireAdminAuth, async (req, res, next) => {
try {
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id }, include: { brand: true, subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } }, employees: true } })
const company = await prisma.company.findUniqueOrThrow({
where: { id: req.params.id },
include: {
brand: true,
contractSettings: true,
accountingSettings: true,
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
employees: true,
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
},
})
res.json({ data: company })
} catch (err) { next(err) }
})
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
try {
res.json({ data: await getMarketplaceHomepageContent() })
} catch (err) { next(err) }
})
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { homepage } = z.object({ homepage: marketplaceHomepageConfigSchema }).parse(req.body)
const saved = await saveMarketplaceHomepageContent(homepage)
await prisma.auditLog.create({
data: {
adminUserId: req.admin.id,
action: 'UPDATE',
resource: 'MarketplaceHomepage',
after: toAuditJson(saved),
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
},
})
res.json({ data: saved })
} catch (err) { next(err) }
})
router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const body = adminCompanyUpdateSchema.parse(req.body)
const before = await prisma.company.findUniqueOrThrow({
where: { id: req.params.id },
include: {
brand: true,
contractSettings: true,
accountingSettings: true,
subscription: true,
},
})
const updated = await prisma.$transaction(async (tx) => {
if (body.company) {
await tx.company.update({
where: { id: req.params.id },
data: body.company,
})
}
if (body.subscription) {
await tx.subscription.upsert({
where: { companyId: req.params.id },
update: {
...body.subscription,
trialStartAt: parseOptionalDate(body.subscription.trialStartAt),
trialEndAt: parseOptionalDate(body.subscription.trialEndAt),
currentPeriodStart: parseOptionalDate(body.subscription.currentPeriodStart),
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
},
create: {
companyId: req.params.id,
plan: body.subscription.plan ?? 'STARTER',
billingPeriod: body.subscription.billingPeriod ?? 'MONTHLY',
status: body.subscription.status ?? 'TRIALING',
currency: body.subscription.currency ?? 'MAD',
trialStartAt: parseOptionalDate(body.subscription.trialStartAt),
trialEndAt: parseOptionalDate(body.subscription.trialEndAt),
currentPeriodStart: parseOptionalDate(body.subscription.currentPeriodStart),
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
cancelAtPeriodEnd: body.subscription.cancelAtPeriodEnd ?? false,
},
})
}
if (body.brand) {
const currentCompany = await tx.company.findUniqueOrThrow({ where: { id: req.params.id } })
await tx.brandSettings.upsert({
where: { companyId: req.params.id },
update: body.brand,
create: {
companyId: req.params.id,
displayName: body.brand.displayName ?? currentCompany.name,
subdomain: body.brand.subdomain ?? currentCompany.slug,
paymentMethodsEnabled: before.brand?.paymentMethodsEnabled ?? [],
...body.brand,
},
})
}
if (body.contractSettings) {
await tx.contractSettings.upsert({
where: { companyId: req.params.id },
update: body.contractSettings,
create: {
companyId: req.params.id,
...body.contractSettings,
},
})
}
if (body.accountingSettings) {
await tx.accountingSettings.upsert({
where: { companyId: req.params.id },
update: body.accountingSettings,
create: {
companyId: req.params.id,
...body.accountingSettings,
},
})
}
return tx.company.findUniqueOrThrow({
where: { id: req.params.id },
include: {
brand: true,
contractSettings: true,
accountingSettings: true,
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
employees: true,
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
},
})
})
await prisma.auditLog.create({
data: {
adminUserId: req.admin.id,
action: 'UPDATE_COMPANY',
resource: 'Company',
resourceId: req.params.id,
companyId: req.params.id,
before: toAuditJson(before),
after: toAuditJson(body),
ipAddress: req.ip,
},
})
res.json({ data: updated })
} catch (err) { next(err) }
})
router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { status, reason } = z.object({ status: z.enum(['ACTIVE','SUSPENDED','CANCELLED']), reason: z.string().optional() }).parse(req.body)
+91
View File
@@ -37,6 +37,97 @@ const brandSchema = z.object({
paypalEmail: z.string().email().optional(),
paypalMerchantId: z.string().optional(),
isListedOnMarketplace: z.boolean().optional(),
homePageConfig: z.object({
heroTitle: z.union([z.string(), z.null()]).optional(),
heroDescription: z.union([z.string(), z.null()]).optional(),
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
pricingTitle: z.union([z.string(), z.null()]).optional(),
pricingDescription: z.union([z.string(), z.null()]).optional(),
showOffers: z.boolean().optional(),
showVehicles: z.boolean().optional(),
showPricing: z.boolean().optional(),
layout: z.object({
items: z.array(z.object({
id: z.string().min(1),
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
x: z.number().int().min(1),
y: z.number().int().min(1),
w: z.number().int().min(1),
h: z.number().int().min(1),
})),
}).optional(),
}).optional(),
menuConfig: z.object({
aboutLabel: z.union([z.string(), z.null()]).optional(),
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
offersLabel: z.union([z.string(), z.null()]).optional(),
pricingLabel: z.union([z.string(), z.null()]).optional(),
blogLabel: z.union([z.string(), z.null()]).optional(),
contactLabel: z.union([z.string(), z.null()]).optional(),
bookCarLabel: z.union([z.string(), z.null()]).optional(),
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
showAbout: z.boolean().optional(),
showVehicles: z.boolean().optional(),
showOffers: z.boolean().optional(),
showPricing: z.boolean().optional(),
showBlog: z.boolean().optional(),
showContact: z.boolean().optional(),
pageSections: z.object({
home: z.object({
hero: z.boolean().optional(),
offers: z.boolean().optional(),
vehicles: z.boolean().optional(),
pricing: z.boolean().optional(),
}).optional(),
about: z.object({
hero: z.boolean().optional(),
highlights: z.boolean().optional(),
details: z.boolean().optional(),
}).optional(),
offers: z.object({
header: z.boolean().optional(),
grid: z.boolean().optional(),
}).optional(),
pricing: z.object({
hero: z.boolean().optional(),
plans: z.boolean().optional(),
payments: z.boolean().optional(),
faq: z.boolean().optional(),
}).optional(),
vehicles: z.object({
header: z.boolean().optional(),
filters: z.boolean().optional(),
grid: z.boolean().optional(),
}).optional(),
vehicleDetail: z.object({
gallery: z.boolean().optional(),
summary: z.boolean().optional(),
}).optional(),
blog: z.object({
hero: z.boolean().optional(),
posts: z.boolean().optional(),
}).optional(),
contact: z.object({
content: z.boolean().optional(),
}).optional(),
booking: z.object({
content: z.boolean().optional(),
}).optional(),
bookingConfirmation: z.object({
hero: z.boolean().optional(),
summary: z.boolean().optional(),
actions: z.boolean().optional(),
}).optional(),
}).optional(),
}).optional(),
})
const contractSettingsSchema = z.object({
+9
View File
@@ -7,9 +7,18 @@ import { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
import * as paypal from '../services/paypalService'
import { getMarketplaceHomepageContent } from '../services/platformContentService'
const router = Router()
router.get('/platform/homepage', async (_req, res, next) => {
try {
res.json({ data: await getMarketplaceHomepageContent() })
} catch (err) {
next(err)
}
})
function isDatabaseUnavailableError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false