502 lines
18 KiB
TypeScript
502 lines
18 KiB
TypeScript
import { Router } from 'express'
|
|
import { z } from 'zod'
|
|
import multer from 'multer'
|
|
import { prisma } from '../lib/prisma'
|
|
import { uploadImage } from '../lib/storage'
|
|
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
|
import { requireTenant } from '../middleware/requireTenant'
|
|
import { requireSubscription } from '../middleware/requireSubscription'
|
|
|
|
const router = Router()
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
|
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
|
|
|
const companySchema = z.object({
|
|
name: z.string().min(1).optional(),
|
|
email: z.string().email().optional(),
|
|
phone: z.string().optional(),
|
|
address: z.record(z.unknown()).optional(),
|
|
})
|
|
|
|
const brandSchema = z.object({
|
|
displayName: z.string().min(1).optional(),
|
|
tagline: z.string().optional(),
|
|
primaryColor: z.string().optional(),
|
|
accentColor: z.string().optional(),
|
|
publicEmail: z.string().email().optional(),
|
|
publicPhone: z.string().optional(),
|
|
publicAddress: z.string().optional(),
|
|
publicCity: z.string().optional(),
|
|
publicCountry: z.string().optional(),
|
|
websiteUrl: z.string().url().optional(),
|
|
whatsappNumber: z.string().optional(),
|
|
defaultLocale: z.string().optional(),
|
|
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
|
amanpayMerchantId: z.string().optional(),
|
|
amanpaySecretKey: z.string().optional(),
|
|
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({
|
|
legalName: z.string().optional(),
|
|
registrationNumber: z.string().optional(),
|
|
taxId: z.string().optional(),
|
|
terms: z.string().optional(),
|
|
fuelPolicy: z.string().optional(),
|
|
depositPolicy: z.string().optional(),
|
|
lateFeePolicy: z.string().optional(),
|
|
damagePolicy: z.string().optional(),
|
|
contractFooterNote: z.string().optional(),
|
|
invoiceFooterNote: z.string().optional(),
|
|
signatureRequired: z.boolean().optional(),
|
|
showTax: z.boolean().optional(),
|
|
taxRate: z.number().optional(),
|
|
taxLabel: z.string().optional(),
|
|
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
|
fuelPolicyNote: z.string().optional(),
|
|
fuelChargePerLiter: z.number().int().optional(),
|
|
fuelShortfallFee: z.number().int().optional(),
|
|
lateFeePerHour: z.number().int().optional(),
|
|
additionalDriverCharge: z.enum(['FREE', 'PER_DAY', 'FLAT']).optional(),
|
|
additionalDriverDailyRate: z.number().int().optional(),
|
|
additionalDriverFlatRate: z.number().int().optional(),
|
|
})
|
|
|
|
const insurancePolicySchema = z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().optional(),
|
|
type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']),
|
|
chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']),
|
|
chargeValue: z.number().int().min(0),
|
|
isRequired: z.boolean().default(false),
|
|
isActive: z.boolean().default(true),
|
|
sortOrder: z.number().int().default(0),
|
|
})
|
|
|
|
const pricingRuleSchema = z.object({
|
|
name: z.string().min(1),
|
|
type: z.enum(['SURCHARGE', 'DISCOUNT']),
|
|
condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']),
|
|
conditionValue: z.number().int(),
|
|
adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']),
|
|
adjustmentValue: z.number().int(),
|
|
isActive: z.boolean().default(true),
|
|
description: z.string().optional(),
|
|
})
|
|
|
|
const accountingSettingsSchema = 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: z.string().email().optional(),
|
|
accountantName: z.string().optional(),
|
|
autoSendReport: z.boolean().optional(),
|
|
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
|
})
|
|
|
|
function buildPaymentMethodsEnabled(input: {
|
|
amanpayMerchantId?: string | null
|
|
amanpaySecretKey?: string | null
|
|
paypalEmail?: string | null
|
|
}) {
|
|
const methods: Array<'AMANPAY' | 'PAYPAL'> = []
|
|
if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY')
|
|
if (input.paypalEmail) methods.push('PAYPAL')
|
|
return methods
|
|
}
|
|
|
|
router.get('/me', async (req, res, next) => {
|
|
try {
|
|
const company = await prisma.company.findUniqueOrThrow({
|
|
where: { id: req.companyId },
|
|
include: {
|
|
brand: true,
|
|
subscription: true,
|
|
_count: { select: { vehicles: true, customers: true, reservations: true } },
|
|
},
|
|
})
|
|
res.json({ data: company })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.patch('/me', async (req, res, next) => {
|
|
try {
|
|
const body = companySchema.parse(req.body)
|
|
const company = await prisma.company.update({
|
|
where: { id: req.companyId },
|
|
data: body as any,
|
|
})
|
|
res.json({ data: company })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/me/brand', async (req, res, next) => {
|
|
try {
|
|
const brand = await prisma.brandSettings.findUnique({
|
|
where: { companyId: req.companyId },
|
|
})
|
|
res.json({ data: brand })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.patch('/me/brand', async (req, res, next) => {
|
|
try {
|
|
const body = brandSchema.parse(req.body)
|
|
const current = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } })
|
|
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
|
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
|
amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey,
|
|
paypalEmail: body.paypalEmail ?? current?.paypalEmail,
|
|
})
|
|
const brand = await prisma.brandSettings.upsert({
|
|
where: { companyId: req.companyId },
|
|
update: { ...body, paymentMethodsEnabled },
|
|
create: {
|
|
companyId: req.companyId,
|
|
displayName: body.displayName ?? req.company.name,
|
|
subdomain: req.company.slug,
|
|
paymentMethodsEnabled,
|
|
...body,
|
|
},
|
|
})
|
|
res.json({ data: brand })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'missing_file', message: 'A logo file is required', statusCode: 400 })
|
|
}
|
|
const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'logo')
|
|
const brand = await prisma.brandSettings.upsert({
|
|
where: { companyId: req.companyId },
|
|
update: { logoUrl: url },
|
|
create: {
|
|
companyId: req.companyId,
|
|
displayName: req.company.name,
|
|
subdomain: req.company.slug,
|
|
logoUrl: url,
|
|
},
|
|
})
|
|
res.json({ data: brand })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'missing_file', message: 'A hero image file is required', statusCode: 400 })
|
|
}
|
|
const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'hero')
|
|
const brand = await prisma.brandSettings.upsert({
|
|
where: { companyId: req.companyId },
|
|
update: { heroImageUrl: url },
|
|
create: {
|
|
companyId: req.companyId,
|
|
displayName: req.company.name,
|
|
subdomain: req.company.slug,
|
|
heroImageUrl: url,
|
|
},
|
|
})
|
|
res.json({ data: brand })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/me/brand/subdomain/check', async (req, res, next) => {
|
|
try {
|
|
const { subdomain } = z.object({ subdomain: z.string().min(3) }).parse(req.body)
|
|
const existing = await prisma.brandSettings.findFirst({
|
|
where: { subdomain, companyId: { not: req.companyId } },
|
|
})
|
|
res.json({ data: { available: !existing } })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/me/brand/custom-domain', async (req, res, next) => {
|
|
try {
|
|
const { customDomain } = z.object({ customDomain: z.string().min(3) }).parse(req.body)
|
|
const normalized = customDomain.toLowerCase().trim()
|
|
const existing = await prisma.brandSettings.findFirst({
|
|
where: { customDomain: normalized, companyId: { not: req.companyId } },
|
|
})
|
|
if (existing) {
|
|
return res.status(409).json({ error: 'domain_taken', message: 'This custom domain is already in use', statusCode: 409 })
|
|
}
|
|
const brand = await prisma.brandSettings.upsert({
|
|
where: { companyId: req.companyId },
|
|
update: {
|
|
customDomain: normalized,
|
|
customDomainVerified: false,
|
|
customDomainAddedAt: new Date(),
|
|
},
|
|
create: {
|
|
companyId: req.companyId,
|
|
displayName: req.company.name,
|
|
subdomain: req.company.slug,
|
|
customDomain: normalized,
|
|
customDomainVerified: false,
|
|
customDomainAddedAt: new Date(),
|
|
},
|
|
})
|
|
res.json({ data: brand })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/me/brand/custom-domain/status', async (req, res, next) => {
|
|
try {
|
|
const brand = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } })
|
|
const customDomain = brand?.customDomain ?? null
|
|
res.json({
|
|
data: {
|
|
customDomain,
|
|
verified: brand?.customDomainVerified ?? false,
|
|
status: customDomain ? (brand?.customDomainVerified ? 'verified' : 'pending_dns') : 'not_configured',
|
|
dnsTarget: process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET ?? 'cname.vercel-dns.com',
|
|
},
|
|
})
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.delete('/me/brand/custom-domain', async (req, res, next) => {
|
|
try {
|
|
const brand = await prisma.brandSettings.updateMany({
|
|
where: { companyId: req.companyId },
|
|
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
|
|
})
|
|
res.json({ data: { success: brand.count > 0 } })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/me/contract-settings', async (req, res, next) => {
|
|
try {
|
|
const settings = await prisma.contractSettings.findUnique({ where: { companyId: req.companyId } })
|
|
res.json({ data: settings })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.patch('/me/contract-settings', async (req, res, next) => {
|
|
try {
|
|
const body = contractSettingsSchema.parse(req.body)
|
|
const settings = await prisma.contractSettings.upsert({
|
|
where: { companyId: req.companyId },
|
|
update: body,
|
|
create: { companyId: req.companyId, ...body },
|
|
})
|
|
res.json({ data: settings })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/me/insurance-policies', async (req, res, next) => {
|
|
try {
|
|
const policies = await prisma.insurancePolicy.findMany({
|
|
where: { companyId: req.companyId },
|
|
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
|
})
|
|
res.json({ data: policies })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/me/insurance-policies', async (req, res, next) => {
|
|
try {
|
|
const body = insurancePolicySchema.parse(req.body)
|
|
const policy = await prisma.insurancePolicy.create({ data: { companyId: req.companyId, ...body } })
|
|
res.status(201).json({ data: policy })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.patch('/me/insurance-policies/:id', async (req, res, next) => {
|
|
try {
|
|
const body = insurancePolicySchema.partial().parse(req.body)
|
|
const policy = await prisma.insurancePolicy.updateMany({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
data: body,
|
|
})
|
|
if (policy.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 })
|
|
const updated = await prisma.insurancePolicy.findUniqueOrThrow({ where: { id: req.params.id } })
|
|
res.json({ data: updated })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.delete('/me/insurance-policies/:id', async (req, res, next) => {
|
|
try {
|
|
const deleted = await prisma.insurancePolicy.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
|
|
if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 })
|
|
res.json({ data: { success: true } })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/me/pricing-rules', async (req, res, next) => {
|
|
try {
|
|
const rules = await prisma.pricingRule.findMany({
|
|
where: { companyId: req.companyId },
|
|
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
|
|
})
|
|
res.json({ data: rules })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/me/pricing-rules', async (req, res, next) => {
|
|
try {
|
|
const body = pricingRuleSchema.parse(req.body)
|
|
const rule = await prisma.pricingRule.create({ data: { companyId: req.companyId, ...body } })
|
|
res.status(201).json({ data: rule })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.patch('/me/pricing-rules/:id', async (req, res, next) => {
|
|
try {
|
|
const body = pricingRuleSchema.partial().parse(req.body)
|
|
const updated = await prisma.pricingRule.updateMany({
|
|
where: { id: req.params.id, companyId: req.companyId },
|
|
data: body,
|
|
})
|
|
if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 })
|
|
const rule = await prisma.pricingRule.findUniqueOrThrow({ where: { id: req.params.id } })
|
|
res.json({ data: rule })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
|
|
try {
|
|
const deleted = await prisma.pricingRule.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
|
|
if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 })
|
|
res.json({ data: { success: true } })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/me/accounting-settings', async (req, res, next) => {
|
|
try {
|
|
const settings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
|
|
res.json({ data: settings })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.patch('/me/accounting-settings', async (req, res, next) => {
|
|
try {
|
|
const body = accountingSettingsSchema.parse(req.body)
|
|
const settings = await prisma.accountingSettings.upsert({
|
|
where: { companyId: req.companyId },
|
|
update: body,
|
|
create: { companyId: req.companyId, ...body },
|
|
})
|
|
res.json({ data: settings })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.get('/me/api-key', async (req, res, next) => {
|
|
try {
|
|
const company = await prisma.company.findUniqueOrThrow({
|
|
where: { id: req.companyId },
|
|
select: { apiKey: true },
|
|
})
|
|
res.json({ data: company })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
router.post('/me/api-key/regenerate', async (req, res, next) => {
|
|
try {
|
|
const company = await prisma.company.update({
|
|
where: { id: req.companyId },
|
|
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
|
|
select: { apiKey: true },
|
|
})
|
|
res.json({ data: company })
|
|
} catch (err) { next(err) }
|
|
})
|
|
|
|
export default router
|