redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
+6
View File
@@ -0,0 +1,6 @@
DATABASE_URL=postgresql://user:replace-with-password@host:5432/db
REDIS_URL=redis://localhost:6379
JWT_SECRET=replace-with-64-byte-random-secret
JWT_EXPIRY=8h
NODE_ENV=test
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
+73
View File
@@ -0,0 +1,73 @@
{
"name": "@rentaldrivego/api",
"version": "1.0.0",
"private": true,
"scripts": {
"predev": "npm run build --workspace @rentaldrivego/types",
"dev": "node ../../scripts/run-with-env-file.cjs ../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/index.ts",
"prebuild": "npm run build --workspace @rentaldrivego/types",
"build": "tsc",
"prestart": "npm run build --workspace @rentaldrivego/types",
"pretype-check": "npm run build --workspace @rentaldrivego/types",
"start": "node dist/index.js",
"type-check": "tsc --noEmit",
"pretest": "npm run build --workspace @rentaldrivego/types",
"test": "vitest run",
"test:watch": "vitest",
"pretest:integration": "npm run build --workspace @rentaldrivego/types",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:watch": "vitest --config vitest.integration.config.ts",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:watch": "vitest --config vitest.e2e.config.ts",
"test:api": "vitest run --config vitest.api.config.ts",
"test:api:watch": "vitest --config vitest.api.config.ts"
},
"dependencies": {
"@react-pdf/renderer": "^3.4.3",
"@rentaldrivego/database": "*",
"@rentaldrivego/types": "*",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dayjs": "^1.11.11",
"express": "^4.19.2",
"express-rate-limit": "^8.5.1",
"firebase-admin": "^12.1.0",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
"nodemailer": "^8.0.10",
"otplib": "^12.0.1",
"qrcode": "^1.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"resend": "^3.2.0",
"socket.io": "^4.7.5",
"swagger-ui-express": "^5.0.1",
"twilio": "^5.1.0",
"zod": "^3.23.0",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.11",
"@types/node": "^20.12.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.8",
"supertest": "^7.0.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
}
+240
View File
@@ -0,0 +1,240 @@
import express from 'express'
import cors, { type CorsOptions } from 'cors'
import helmet from 'helmet'
import morgan from 'morgan'
import swaggerUi from 'swagger-ui-express'
import { openApiDocument } from './swagger/openapi'
import { getPublicStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
import { requestIdMiddleware } from './middleware/requestId'
// ─── Module routes ────────────────────────────────────────────
import webhookRouter from './modules/webhooks/webhook.routes'
import companyAuthRouter from './modules/auth/auth.company.routes'
import employeeAuthRouter from './modules/auth/auth.employee.routes'
import accountAuthRouter from './modules/auth/auth.account.routes'
import renterAuthRouter from './modules/auth/auth.renter.routes'
import teamRouter from './modules/team/team.routes'
import offersRouter from './modules/offers/offer.routes'
import analyticsRouter from './modules/analytics/analytics.routes'
import notificationsRouter from './modules/notifications/notification.routes'
import adminRouter from './modules/admin/admin.routes'
import subscriptionsRouter, {
subscriptionPublicRouter,
subscriptionWebhookRouter,
} from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes'
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 marketplaceRouter from './modules/marketplace/marketplace.routes'
import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes'
import licenseValidationRouter from './modules/licenses/license.validation.routes'
// ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware'
const v1 = '/api/v1'
const defaultCorsOrigins = [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002',
'http://localhost:4000',
'http://127.0.0.1:3000',
'http://127.0.0.1:3001',
'http://127.0.0.1:3002',
'http://127.0.0.1:4000',
]
export const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
: defaultCorsOrigins
function isAllowedLocalDevOrigin(origin: string) {
if (process.env.NODE_ENV === 'production') return false
try {
const url = new URL(origin)
return (
url.protocol === 'http:' &&
['localhost', '127.0.0.1'].includes(url.hostname) &&
['3000', '3001', '3002', '4000'].includes(url.port)
)
} catch {
return false
}
}
export function isCorsOriginAllowed(origin: string | undefined) {
if (!origin) return true
return corsOrigins.includes(origin) || isAllowedLocalDevOrigin(origin)
}
export const corsOptions: CorsOptions = {
origin(origin, callback) {
callback(null, isCorsOriginAllowed(origin))
},
credentials: true,
}
const routeDocs = [
{ method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
{ 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}/marketplace/cities`, description: 'Marketplace cities' },
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
{ 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' },
{ method: 'GET', path: `${v1}/subscriptions/features`, description: 'Subscription plan features' },
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
]
export function createApp() {
const app = express()
const corsMiddleware = cors(corsOptions)
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1)
}
app.use(requestIdMiddleware)
app.use((req, res, next) => {
if (req.headers['x-middleware-subrequest']) {
return res.status(400).json({ error: 'bad_request', message: 'Unsupported internal request header', statusCode: 400 })
}
next()
})
app.use(corsMiddleware)
// Customer identity documents must never be anonymously retrievable from the
// public storage mount, even if an older raw storage URL leaks.
app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => {
res.status(404).end()
})
// Public storage assets (logos, vehicle photos, etc.) must be loadable cross-origin
// by the browser. Without this header, helmet's default CORP: same-origin reaches
// 404 responses (when express.static calls next() on a miss) and the browser blocks
// the response even for broken-image cases, producing ERR_BLOCKED_BY_RESPONSE.
app.use('/storage', (_req, res, next) => {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
next()
})
// Serve only explicitly public uploaded assets. Private documents are resolved
// through authenticated API routes such as /customers/:id/license-image.
app.use('/storage', express.static(getPublicStorageRoot()))
// Swagger UI — mounted before helmet so its assets are not blocked by CSP
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
// Webhooks must use raw body BEFORE express.json(); signature verification
// must never reconstruct the payload with JSON.stringify(req.body).
app.use(`${v1}/webhooks`, express.raw({ type: 'application/json' }), webhookRouter)
app.use(`${v1}/payments/webhooks`, express.raw({ type: 'application/json' }))
app.use(`${v1}/subscriptions/webhooks`, express.raw({ type: 'application/json' }))
// Let /storage responses manage CORP explicitly so missing files still return
// a normal cross-origin 404 instead of being blocked by Helmet's default
// same-origin policy. Keep CSP explicit instead of letting browser security
// drift into wishful thinking with headers.
app.use(helmet({
crossOriginResourcePolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
objectSrc: ["'none'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: ["'self'", 'https:', 'wss:'],
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null,
},
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
frameguard: { action: 'deny' },
}))
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
app.use(express.json({ limit: '10mb' }))
// ─── API Routes ─────────────────────────────────────────────
app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter)
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
app.use(`${v1}/admin/auth`, authLimiter)
app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
app.use(`${v1}/team`, apiLimiter, teamRouter)
app.use(`${v1}/customers`, apiLimiter, customersRouter)
app.use(`${v1}/offers`, apiLimiter, offersRouter)
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
// ─── Health / Docs ──────────────────────────────────────────
app.get(v1, (_req, res) => {
res.json({
name: 'rentaldrivego-api',
version: '1.0.0',
baseUrl: v1,
health: '/health',
docs: `${v1}/docs`,
openApi: `${v1}/openapi.json`,
})
})
app.get('/health', (_req, res) => {
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
})
app.get(`${v1}/docs`, (_req, res) => {
res.json({
name: 'rentaldrivego-api',
version: '1.0.0',
baseUrl: v1,
routes: routeDocs,
swaggerUi: '/docs',
openApi: '/api/v1/openapi.json',
})
})
// ─── Error handler ──────────────────────────────────────────
app.use(errorMiddleware)
return app
}
+331
View File
@@ -0,0 +1,331 @@
{
"marketplaceHomepage": {
"en": {
"sections": [
"hero",
"surface",
"pillars",
"audiences",
"features",
"howitworks",
"steps",
"testimonials",
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "Marketplace discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.",
"startTrial": "Start free trial",
"exploreVehicles": "Explore vehicles",
"surfaceLabel": "Marketplace surface",
"surfaceTitle": "Designed for two audiences at once.",
"surfaceBody": "Operators need control, renters need confidence. The marketplace should show both without feeling like a template.",
"liveLabel": "Live network",
"trustedFleets": "Trusted fleets",
"brandedFlows": "Branded booking flows",
"multiTenant": "Multi-tenant operations",
"companyKicker": "Operator workflow",
"companyTitle": "Manage inventory, offers, billing, and staff from one control layer.",
"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 marketplace 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 marketplace discovery and branded booking pages."
},
{
"title": "Qualified discovery",
"body": "Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast."
},
{
"title": "Direct revenue path",
"body": "Bookings move into the companys own payment flow, so customer ownership and cash collection stay with the business."
}
],
"metrics": [
{
"value": "01",
"label": "Shared marketplace visibility"
},
{
"value": "02",
"label": "Direct company checkout"
},
{
"value": "03",
"label": "Company-owned payments"
}
],
"featureLabel": "What the product covers",
"features": [
"Fleet management with multi-photo uploads",
"Marketplace offers and redirect booking flow",
"Branded public booking site per company",
"Customer CRM, analytics, and billing controls"
],
"howitworksKicker": "GETTING STARTED",
"howitworksTitle": "How It Works",
"howitworksSteps": [
{
"number": "1",
"title": "Create Account",
"description": "Sign up for your company workspace and choose your pricing plan for the 90-day free trial."
},
{
"number": "2",
"title": "Add Your Fleet",
"description": "Upload vehicle photos, set prices, and create offers visible on the marketplace."
},
{
"number": "3",
"title": "Start Selling",
"description": "Renters discover your fleet, and bookings flow directly to your branded checkout."
}
],
"stepsTitle": "How companies launch",
"steps": [
{
"step": "1",
"title": "Create your company workspace",
"body": "Pick a plan, launch your 90-day trial, and verify the owner account."
},
{
"step": "2",
"title": "Publish vehicles and offers",
"body": "Upload photos once and control what appears on the marketplace and branded site."
},
{
"step": "3",
"title": "Accept bookings on your own site",
"body": "Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow."
}
],
"stepLabel": "Step",
"readyKicker": "Ready to launch",
"readyTitle": "The marketplace homepage should explain the product in seconds.",
"readyBody": "Use the marketplace 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"
},
"fr": {
"sections": [
"hero",
"surface",
"pillars",
"audiences",
"features",
"howitworks",
"steps",
"testimonials",
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "Une vitrine marketplace plus claire et plus percutante.",
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
"startTrial": "Commencer lessai gratuit",
"exploreVehicles": "Explorer les véhicules",
"surfaceLabel": "Vitrine marketplace",
"surfaceTitle": "Pensée pour deux audiences à la fois.",
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.",
"liveLabel": "Réseau actif",
"trustedFleets": "Flottes fiables",
"brandedFlows": "Parcours de marque",
"multiTenant": "Opérations multi-tenant",
"companyKicker": "Flux opérateur",
"companyTitle": "Pilotez linventaire, les offres, la facturation et l’équipe depuis un seul centre de contrôle.",
"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 dentreprise.",
"renterBody": "La marketplace sert de moteur de découverte, pas dagré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 dadministration vers la découverte marketplace et les pages de réservation de marque."
},
{
"title": "Découverte qualifiée",
"body": "Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement."
},
{
"title": "Revenus en direct",
"body": "Les réservations basculent vers le paiement propre à lentreprise afin de préserver la relation client et lencaissement."
}
],
"metrics": [
{
"value": "01",
"label": "Visibilité marketplace partagée"
},
{
"value": "02",
"label": "Paiement direct entreprise"
},
{
"value": "03",
"label": "Paiements gérés par lentreprise"
}
],
"featureLabel": "Ce que couvre le produit",
"features": [
"Gestion de flotte avec téléversement de plusieurs photos",
"Offres marketplace 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 90 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 marketplace."
},
{
"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",
"title": "Créez votre espace entreprise",
"body": "Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire."
},
{
"step": "2",
"title": "Publiez véhicules et offres",
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
},
{
"step": "3",
"title": "Acceptez les réservations sur votre site",
"body": "Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation."
}
],
"stepLabel": "Étape",
"readyKicker": "Prêt à démarrer",
"readyTitle": "La page daccueil marketplace doit présenter le produit en quelques secondes.",
"readyBody": "Utilisez la marketplace 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 lespace"
},
"ar": {
"sections": [
"hero",
"surface",
"pillars",
"audiences",
"features",
"howitworks",
"steps",
"testimonials",
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "واجهة سوق أكثر وضوحاً وتأثيراً.",
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة ذاتها.",
"startTrial": "ابدأ التجربة المجانية",
"exploreVehicles": "استكشف السيارات",
"surfaceLabel": "واجهة السوق",
"surfaceTitle": "مصممة لجمهورين في الوقت نفسه.",
"surfaceBody": "المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.",
"liveLabel": "شبكة نشطة",
"trustedFleets": "أساطيل موثوقة",
"brandedFlows": "مسارات حجز مخصصة",
"multiTenant": "عمليات متعددة الشركات",
"companyKicker": "سير عمل المشغل",
"companyTitle": "تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.",
"companyBody": "انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.",
"renterKicker": "تجربة المستأجر",
"renterTitle": "دع المستأجرين يقارنون بسرعة ثم وجّههم إلى موقع الشركة المناسب.",
"renterBody": "هذه المنصة محرك لاكتشاف الخيارات، وليست مُجمِّعاً بلا مسار واضح. ابحث هنا، احجز هناك، وادفع مباشرة للشركة.",
"pillars": [
{
"title": "نشر موحد",
"body": "صور السيارات والأسعار والعروض تنتقل من سير إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
},
{
"title": "اكتشاف مؤهل",
"body": "تساعد العروض المميزة، والتصفح حسب الموقع، وإشارات السمعة المستأجرين على تحديد خياراتهم بسرعة."
},
{
"title": "مسار إيراد مباشر",
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى علاقة العميل والتحصيل المالي بيد الشركة نفسها."
}
],
"metrics": [
{
"value": "01",
"label": "ظهور مشترك في السوق"
},
{
"value": "02",
"label": "دفع مباشر للشركة"
},
{
"value": "03",
"label": "مدفوعات مملوكة للشركة"
}
],
"featureLabel": "ما الذي يقدمه المنتج",
"features": [
"إدارة الأسطول مع رفع عدة صور",
"عروض السوق والتحويل إلى مسار الحجز",
"موقع حجز عام مخصص لكل شركة",
"إدارة العملاء والتحليلات والفوترة"
],
"howitworksKicker": "البدء",
"howitworksTitle": "كيف يعمل",
"howitworksSteps": [
{
"number": "1",
"title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
},
{
"number": "2",
"title": "أضف أسطولك",
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
},
{
"number": "3",
"title": "ابدأ البيع",
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
}
],
"stepsTitle": "كيف تبدأ الشركات",
"steps": [
{
"step": "1",
"title": "أنشئ مساحة عمل شركتك",
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك."
},
{
"step": "2",
"title": "انشر السيارات والعروض",
"body": "ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص."
},
{
"step": "3",
"title": "استقبل الحجوزات على موقعك",
"body": "يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك."
}
],
"stepLabel": "الخطوة",
"readyKicker": "جاهز للانطلاق",
"readyTitle": "يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.",
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك، بحيث تبقى الأسعار والمدفوعات والثقة مرتبطة بنشاطك.",
"viewPricing": "عرض الأسعار",
"createWorkspace": "إنشاء المساحة"
}
}
}
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import type { Response } from 'express'
import { z } from 'zod'
import { AppError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError } from './index'
import { errorMiddleware } from './errorMiddleware'
function createResponseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
function handle(error: unknown) {
const res = createResponseStub()
errorMiddleware(error, {} as any, res, vi.fn())
return res
}
describe('AppError subclasses', () => {
it.each([
[new ValidationError('Bad payload'), 400, 'validation_error'],
[new UnauthorizedError(), 401, 'unauthorized'],
[new ForbiddenError(), 403, 'forbidden'],
[new NotFoundError(), 404, 'not_found'],
[new ConflictError(), 409, 'conflict'],
])('sets stable status and error code for %s', (error, statusCode, code) => {
expect(error).toBeInstanceOf(AppError)
expect(error.statusCode).toBe(statusCode)
expect(error.error).toBe(code)
})
})
describe('errorMiddleware', () => {
it('normalizes Zod errors into validation responses', () => {
const result = z.object({ email: z.string().email() }).safeParse({ email: 'not-an-email' })
expect(result.success).toBe(false)
const res = handle(result.success ? new Error('unexpected') : result.error)
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: 'validation_error',
message: 'Invalid request body',
statusCode: 400,
issues: expect.any(Array),
}))
})
it('normalizes Prisma not found errors', () => {
const res = handle({ code: 'P2025' })
expect(res.status).toHaveBeenCalledWith(404)
expect(res.json).toHaveBeenCalledWith({
error: 'not_found',
message: 'Resource not found',
statusCode: 404,
})
})
it('normalizes Prisma unique constraint errors', () => {
const res = handle({ code: 'P2002' })
expect(res.status).toHaveBeenCalledWith(409)
expect(res.json).toHaveBeenCalledWith({
error: 'conflict',
message: 'A resource with this value already exists',
statusCode: 409,
})
})
it('preserves AppError metadata in the response body', () => {
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'payment_required',
message: 'Plan required',
statusCode: 402,
requiredPlan: 'PRO',
})
})
it('falls back to a 500 internal error response', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const res = handle(new Error('boom'))
expect(res.status).toHaveBeenCalledWith(500)
expect(res.json).toHaveBeenCalledWith({ error: 'internal_error', message: 'Internal server error', statusCode: 500, requestId: undefined })
spy.mockRestore()
})
})
+48
View File
@@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express'
import { AppError } from './index'
function withRequestId(req: Request, payload: Record<string, unknown>) {
return { ...payload, requestId: req.requestId }
}
export function errorMiddleware(err: any, req: Request, res: Response, _next: NextFunction) {
if (err.name === 'ZodError') {
return res.status(400).json(withRequestId(req, {
error: 'validation_error',
message: 'Invalid request body',
issues: err.issues,
statusCode: 400,
}))
}
if (err.code === 'P2025') {
return res.status(404).json(withRequestId(req, { error: 'not_found', message: 'Resource not found', statusCode: 404 }))
}
if (err.code === 'P2002') {
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
}
if (err instanceof AppError) {
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
return res.status(err.statusCode).json(withRequestId(req, {
error: err.error,
message: err.statusCode >= 500 ? 'Internal server error' : err.message,
statusCode: err.statusCode,
...(err.statusCode >= 500 ? {} : err.data),
}))
}
const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500
const safeStatusCode = statusCode >= 400 && statusCode < 600 ? statusCode : 500
if (safeStatusCode >= 500) {
console.error('[API Error]', { requestId: req.requestId, err })
}
res.status(safeStatusCode).json(withRequestId(req, {
error: safeStatusCode >= 500 ? 'internal_error' : (err.code ?? 'request_error'),
message: safeStatusCode >= 500 ? 'Internal server error' : (err.message ?? 'Request failed'),
statusCode: safeStatusCode,
}))
}
+43
View File
@@ -0,0 +1,43 @@
export class AppError extends Error {
readonly statusCode: number
readonly error: string
readonly data?: Record<string, unknown>
constructor(message: string, statusCode: number, error: string, data?: Record<string, unknown>) {
super(message)
this.statusCode = statusCode
this.error = error
this.data = data
this.name = this.constructor.name
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation error') {
super(message, 400, 'validation_error')
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404, 'not_found')
}
}
export class ConflictError extends AppError {
constructor(message = 'A resource with this value already exists') {
super(message, 409, 'conflict')
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'forbidden')
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'unauthorized')
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Response } from 'express'
export function ok<T>(res: Response, data: T): void {
res.json({ data })
}
export function created<T>(res: Response, data: T): void {
res.status(201).json({ data })
}
export function noContent(res: Response): void {
res.status(204).end()
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest'
import type { Response } from 'express'
import { created, noContent, ok } from './index'
function createResponseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
end: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
res.end.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('http/respond helpers', () => {
it('ok responds with the expected data envelope', () => {
const res = createResponseStub()
const payload = { id: 'vehicle_1', name: 'Dacia Logan' }
ok(res, payload)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).toHaveBeenCalledWith({ data: payload })
})
it('created responds with status 201 and the expected data envelope', () => {
const res = createResponseStub()
const payload = { id: 'reservation_1' }
created(res, payload)
expect(res.status).toHaveBeenCalledWith(201)
expect(res.json).toHaveBeenCalledWith({ data: payload })
})
it('noContent responds with status 204 and no body', () => {
const res = createResponseStub()
noContent(res)
expect(res.status).toHaveBeenCalledWith(204)
expect(res.end).toHaveBeenCalledWith()
expect(res.json).not.toHaveBeenCalled()
})
})
+83
View File
@@ -0,0 +1,83 @@
import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
['image/webp', ['.webp']],
['image/gif', ['.gif']],
])
/**
* Shared multer instance used by all upload endpoints.
* Files are kept in memory so services receive a Buffer — no temp-file cleanup needed.
*/
export const imageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE, files: 20 },
})
type DetectedFile = { mime: string; ext: string }
export function detectImageType(buffer: Buffer): DetectedFile | null {
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { mime: 'image/jpeg', ext: '.jpg' }
}
if (
buffer.length >= 8 &&
buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 &&
buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a
) {
return { mime: 'image/png', ext: '.png' }
}
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
return { mime: 'image/webp', ext: '.webp' }
}
if (buffer.length >= 6) {
const sig = buffer.subarray(0, 6).toString('ascii')
if (sig === 'GIF87a' || sig === 'GIF89a') return { mime: 'image/gif', ext: '.gif' }
}
return null
}
function assertSafeImageContent(file: Express.Multer.File) {
const detected = detectImageType(file.buffer)
if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
throw new ValidationError(`Unsupported or spoofed image file: ${file.originalname}`)
}
if (file.mimetype !== detected.mime) {
throw new ValidationError(`MIME type does not match file content for "${file.originalname}"`)
}
const ext = path.extname(file.originalname).toLowerCase()
const allowedExtensions = ALLOWED_IMAGE_TYPES.get(detected.mime) ?? []
if (ext && !allowedExtensions.includes(ext)) {
throw new ValidationError(`File extension does not match file content for "${file.originalname}"`)
}
}
/**
* Asserts that a file was provided and is an image by content, not by client claims.
*/
export function assertImageFile(
file: Express.Multer.File | undefined,
fieldLabel = 'file',
): asserts file is Express.Multer.File {
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
assertSafeImageContent(file)
}
/**
* Asserts that at least one file was provided and all files are image content.
*/
export function assertImageFiles(files: Express.Multer.File[], fieldLabel = 'photos'): void {
if (!files || files.length === 0) throw new ValidationError(`At least one ${fieldLabel} file is required`)
for (const file of files) assertSafeImageContent(file)
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { ValidationError } from '../errors'
import { assertImageFile, assertImageFiles } from './index'
const file = (overrides: Partial<Express.Multer.File> = {}) => ({
fieldname: 'file',
originalname: 'photo.jpg',
encoding: '7bit',
mimetype: 'image/jpeg',
size: 123,
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xdb]),
stream: undefined as any,
destination: '',
filename: '',
path: '',
...overrides,
})
describe('upload assertions', () => {
it('accepts a single image file and narrows the route input', () => {
expect(() => assertImageFile(file())).not.toThrow()
})
it('rejects missing single file uploads with a field-specific error', () => {
expect(() => assertImageFile(undefined, 'license image')).toThrow(ValidationError)
expect(() => assertImageFile(undefined, 'license image')).toThrow('A license image is required')
})
it('rejects non-image single file uploads', () => {
expect(() => assertImageFile(file({ mimetype: 'application/pdf', originalname: 'license.pdf' }))).toThrow('MIME type does not match file content')
})
it('accepts multiple image files and rejects empty or mixed batches', () => {
expect(() => assertImageFiles([file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) })])).not.toThrow()
expect(() => assertImageFiles([], 'inspection photos')).toThrow('At least one inspection photos file is required')
expect(() => assertImageFiles([
file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }),
file({ originalname: 'report.pdf', mimetype: 'application/pdf', buffer: Buffer.from('%PDF') }),
])).toThrow('Unsupported or spoofed image file: report.pdf')
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { Request } from 'express'
import type { ZodTypeAny, output } from 'zod'
export function parseBody<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
return schema.parse(req.body)
}
export function parseQuery<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
return schema.parse(req.query)
}
export function parseParams<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
return schema.parse(req.params)
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import type { Request } from 'express'
import { z } from 'zod'
import { parseBody, parseParams, parseQuery } from './index'
describe('http/validate parsers', () => {
it('parseBody returns typed and coerced request body values', () => {
const schema = z.object({ seats: z.coerce.number().int().min(1), brand: z.string().min(1) })
const req = { body: { seats: '5', brand: 'Toyota' } } as Request
expect(parseBody(schema, req)).toEqual({ seats: 5, brand: 'Toyota' })
})
it('parseQuery validates query values and throws ZodError for invalid input', () => {
const schema = z.object({ page: z.coerce.number().int().positive() })
const req = { query: { page: '0' } } as unknown as Request
expect(() => parseQuery(schema, req)).toThrow('Number must be greater than 0')
})
it('parseParams returns validated path parameters', () => {
const schema = z.object({ vehicleId: z.string().min(1) })
const req = { params: { vehicleId: 'veh_123' } } as unknown as Request
expect(parseParams(schema, req)).toEqual({ vehicleId: 'veh_123' })
})
})
+18
View File
@@ -0,0 +1,18 @@
import type { Request } from 'express'
import { ValidationError } from './errors'
export function getRawBodyString(req: Request) {
if (!Buffer.isBuffer(req.body)) {
throw new ValidationError('Webhook route must be mounted with express.raw before JSON parsing')
}
return req.body.toString('utf8')
}
export function parseRawJsonBody<T = unknown>(req: Request): T {
const rawBody = getRawBodyString(req)
try {
return JSON.parse(rawBody) as T
} catch {
throw new ValidationError('Malformed webhook JSON')
}
}
+271
View File
@@ -0,0 +1,271 @@
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Socket } from 'socket.io'
import cron from 'node-cron'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies'
import { sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
const app = createApp()
const server = http.createServer(app)
assertStorageConfiguration()
// ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, {
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return null
for (const chunk of cookieHeader.split(';')) {
const [rawName, ...rawValue] = chunk.trim().split('=')
if (rawName === name) return decodeURIComponent(rawValue.join('='))
}
return null
}
function getSocketSessionToken(socket: Socket): string | undefined {
const explicitToken = socket.handshake.auth?.token
if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim()
const cookieHeader = socket.request.headers.cookie
return (
readCookieFromHeader(cookieHeader, getSessionCookieName('employee')) ??
readCookieFromHeader(cookieHeader, getSessionCookieName('admin')) ??
readCookieFromHeader(cookieHeader, getSessionCookieName('renter')) ??
undefined
)
}
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
const token = getSocketSessionToken(socket)
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
try {
const payload = verifyAnyActorToken(token)
;(socket as any).authenticatedUserId = payload.sub
next()
} catch {
next(new Error('invalid_token'))
}
})
io.on('connection', (socket) => {
const userId = (socket as any).authenticatedUserId as string | undefined
if (userId) {
socket.join(`user:${userId}`)
}
})
// Redis pub/sub → broadcast to connected clients
const subscriber = redis.duplicate()
subscriber.psubscribe('notifications:*', (err) => {
if (err) console.error('[Redis] Subscribe error:', err)
})
subscriber.on('pmessage', (_pattern, channel, message) => {
try {
const parsed = JSON.parse(message)
const userId = channel.replace('notifications:', '')
io.to(`user:${userId}`).emit('notification', parsed)
} catch (err) {
console.error('[Redis] Invalid notification message:', err)
}
})
// ─── Scheduled jobs ───────────────────────────────────────────
// Daily: flag expiring/expired licenses
cron.schedule('0 8 * * *', async () => {
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
for (const c of customers) {
if (!c.licenseExpiry) continue
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
const expired = c.licenseExpiry <= new Date()
const expiring = !expired && daysLeft < 90
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
await prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } })
}
}
})
// Hourly: expire trials that ended without payment
cron.schedule('0 * * * *', async () => {
const n = await runTrialExpirationJob()
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
})
// Hourly: payment_pending → past_due after 7 days
cron.schedule('15 * * * *', async () => {
const n = await runPaymentPendingTimeoutJob()
if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`)
})
// Hourly: past_due → suspended after 7 days
cron.schedule('30 * * * *', async () => {
const n = await runPastDueTimeoutJob()
if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`)
})
// Daily: suspended → cancelled after 16 days; and period-end cancellations
cron.schedule('0 1 * * *', async () => {
const nSuspend = await runSuspensionTimeoutJob()
const nPeriod = await runPeriodEndCancellationJob()
if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`)
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
})
// Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
const subscriptions = await prisma.subscription.findMany({
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
})
for (const sub of subscriptions) {
const owner = sub.company.employees[0]
if (owner) {
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
companyId: sub.companyId,
employeeId: owner.id,
channels: ['IN_APP'],
templateKey: 'subscription.trial_ending',
templateVariables: {
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
},
}).catch((err) => {
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
})
}
}
})
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
cron.schedule('0 8 * * *', async () => {
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
// old overdue log is superseded and notifications stop automatically.
const allCandidates = await prisma.maintenanceLog.findMany({
where: {
OR: [
{ nextDueAt: { lte: in30Days } },
{ nextDueMileage: { not: null } },
],
},
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
orderBy: { performedAt: 'desc' },
})
// Keep only the most-recent log per vehicle+type combination
const latestByKey = new Map<string, typeof allCandidates[number]>()
for (const log of allCandidates) {
const key = `${log.vehicleId}:${log.type}`
if (!latestByKey.has(key)) latestByKey.set(key, log)
}
for (const log of latestByKey.values()) {
const vehicle = log.vehicle
const company = vehicle.company
const recipient = company.employees[0]
if (!recipient) continue
// Determine date-based urgency
let isOverdueByDate = false
let daysLeft: number | null = null
let dueSoonByDate = false
if (log.nextDueAt) {
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
isOverdueByDate = log.nextDueAt <= now
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
}
// Determine odometer-based urgency
let isOverdueByOdometer = false
let kmLeft: number | null = null
let dueSoonByOdometer = false
if (log.nextDueMileage != null && vehicle.mileage != null) {
kmLeft = log.nextDueMileage - vehicle.mileage
isOverdueByOdometer = kmLeft <= 0
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
}
// Skip if the latest log is no longer due (owner has updated it)
const isOverdue = isOverdueByDate || isOverdueByOdometer
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
if (!isOverdue && !isDueSoon) continue
// Dedup: don't send more than once per day for the same log
const alreadySent = await prisma.notification.findFirst({
where: {
type: 'VEHICLE_MAINTENANCE_DUE',
companyId: company.id,
createdAt: { gte: oneDayAgo },
data: { path: ['maintenanceLogId'], equals: log.id },
},
})
if (alreadySent) continue
// Build human-readable description
const dueParts: string[] = []
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
const title = isOverdue
? `Overdue: ${log.type}${vehicle.make} ${vehicle.model}`
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
await prisma.notification.create({
data: {
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channel: 'IN_APP',
status: 'DELIVERED',
},
})
}
})
// ─── Start ────────────────────────────────────────────────────
const PORT = Number(process.env.API_PORT ?? 4000)
const HOST = process.env.API_HOST ?? '0.0.0.0'
server.listen(PORT, HOST, () => {
console.log(`[API] Server running on ${HOST}:${PORT}`)
})
export { app, io }
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import {
formatDate,
marketplaceReservationEmail,
resetPasswordEmail,
signupEmail,
} from './emailTranslations'
describe('emailTranslations', () => {
const trialEnd = new Date('2026-07-15T12:00:00.000Z')
it('renders signup subjects in every supported locale', () => {
expect(signupEmail.subject('en')).toContain('workspace')
expect(signupEmail.subject('fr')).toContain('espace')
expect(signupEmail.subject('ar')).toContain('جاهزة')
})
it('renders localized signup text with billing and provider details', () => {
const text = signupEmail.text({
firstName: 'Aya',
companyName: 'Atlas Cars',
plan: 'PRO',
billingPeriod: 'ANNUAL',
currency: 'MAD',
paymentProvider: 'AmanPay',
trialEnd,
}, 'fr')
expect(text).toContain('Bonjour Aya')
expect(text).toContain('Atlas Cars')
expect(text).toContain('Forfait : PRO (annuel)')
expect(text).toContain('Fournisseur de paiement principal : AmanPay')
})
it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
const html = resetPasswordEmail.html('https://example.test/reset/token', 'Mina', 45, 'ar')
expect(html).toContain('dir="rtl"')
expect(html).toContain('https://example.test/reset/token')
expect(html).toContain('45')
})
it('includes optional contact phone in marketplace reservation HTML when present', () => {
const html = marketplaceReservationEmail.html({
firstName: 'Yassine',
vehicleYear: 2024,
vehicleMake: 'Dacia',
vehicleModel: 'Duster',
companyName: 'Atlas Cars',
startDate: new Date('2026-08-01T00:00:00.000Z'),
endDate: new Date('2026-08-05T00:00:00.000Z'),
totalDays: 4,
rateDisplay: '400.00',
totalDisplay: '1600.00',
email: 'yassine@example.test',
phone: '+212600000000',
}, 'en')
expect(html).toContain('2024 Dacia Duster')
expect(html).toContain('Atlas Cars')
expect(html).toContain('yassine@example.test or +212600000000')
})
it('formats dates with the locale-specific formatter', () => {
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'en')).toContain('2026')
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'fr')).toContain('2026')
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toMatch(/2026|٢٠٢٦/)
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toContain('فبراير')
})
})
+290
View File
@@ -0,0 +1,290 @@
export type Lang = 'en' | 'fr' | 'ar'
function t<T>(map: Record<Lang, T>, lang: Lang): T {
return map[lang] ?? map['fr']
}
const dateLocale: Record<Lang, string> = { en: 'en-GB', fr: 'fr-FR', ar: 'ar-MA' }
export function formatDate(date: Date, lang: Lang, opts?: Intl.DateTimeFormatOptions): string {
return date.toLocaleDateString(dateLocale[lang], opts ?? { year: 'numeric', month: 'long', day: 'numeric' })
}
// ─── Signup confirmation ──────────────────────────────────────────────────────
export const signupEmail = {
subject: (lang: Lang) => t({
en: 'Your workspace is ready — RentalDriveGo',
fr: 'Votre espace de travail est prêt — RentalDriveGo',
ar: 'مساحة عملك جاهزة — RentalDriveGo',
}, lang),
text: (opts: {
firstName: string
companyName: string
plan: string
billingPeriod: string
currency: string
paymentProvider: string
trialEnd: Date
}, lang: Lang): string => {
const trialStr = formatDate(opts.trialEnd, lang)
return t({
en: [
`Hi ${opts.firstName},`,
'',
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
`Currency: ${opts.currency}`,
`Primary payment provider: ${opts.paymentProvider}`,
`Free trial ends on ${trialStr}.`,
'',
'Your workspace is ready. Sign in with the email and password you chose during signup.',
'',
'RentalDriveGo',
].join('\n'),
fr: [
`Bonjour ${opts.firstName},`,
'',
`Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
`Devise : ${opts.currency}`,
`Fournisseur de paiement principal : ${opts.paymentProvider}`,
`La période d'essai gratuit se termine le ${trialStr}.`,
'',
"Votre espace de travail est prêt. Connectez-vous avec l'e-mail et le mot de passe choisis lors de l'inscription.",
'',
'RentalDriveGo',
].join('\n'),
ar: [
`مرحباً ${opts.firstName}،`,
'',
`تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
`العملة: ${opts.currency}`,
`مزود الدفع الرئيسي: ${opts.paymentProvider}`,
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`,
'',
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
'',
'RentalDriveGo',
].join('\n'),
}, lang)
},
}
// ─── Password reset ───────────────────────────────────────────────────────────
export const resetPasswordEmail = {
subject: (lang: Lang) => t({
en: 'Reset your RentalDriveGo password',
fr: 'Réinitialisez votre mot de passe RentalDriveGo',
ar: 'إعادة تعيين كلمة مرور RentalDriveGo',
}, lang),
html: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => {
const isRtl = lang === 'ar'
const dir = isRtl ? 'rtl' : 'ltr'
return t({
en: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<p>Hi ${firstName},</p>
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
<p>This link expires in ${expireMinutes} minutes. If you did not request this, you can safely ignore this email.</p>
<p>RentalDriveGo</p>
</div>`,
fr: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<p>Bonjour ${firstName},</p>
<p>Vous avez demandé la réinitialisation du mot de passe de votre compte RentalDriveGo.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Réinitialiser mon mot de passe</a></p>
<p>Ce lien expire dans ${expireMinutes} minutes. Si vous n'avez pas fait cette demande, ignorez simplement cet e-mail.</p>
<p>RentalDriveGo</p>
</div>`,
ar: `<div dir="rtl" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<p>مرحباً ${firstName}،</p>
<p>طلبت إعادة تعيين كلمة مرور حساب RentalDriveGo الخاص بك.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">إعادة تعيين كلمة المرور</a></p>
<p>تنتهي صلاحية هذا الرابط خلال ${expireMinutes} دقيقة. إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني بأمان.</p>
<p>RentalDriveGo</p>
</div>`,
}, lang)
},
text: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => t({
en: `Hi ${firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${expireMinutes} minutes.\n\nRentalDriveGo`,
fr: `Bonjour ${firstName},\n\nRéinitialisez votre mot de passe ici : ${resetUrl}\n\nCe lien expire dans ${expireMinutes} minutes.\n\nRentalDriveGo`,
ar: `مرحباً ${firstName}،\n\nأعد تعيين كلمة مرورك هنا: ${resetUrl}\n\nينتهي هذا الرابط خلال ${expireMinutes} دقيقة.\n\nRentalDriveGo`,
}, lang),
}
// ─── Marketplace reservation request ─────────────────────────────────────────
export const marketplaceReservationEmail = {
subject: (vehicleName: string, lang: Lang) => t({
en: `Reservation Request Received — ${vehicleName}`,
fr: `Demande de réservation reçue — ${vehicleName}`,
ar: `تم استلام طلب الحجز — ${vehicleName}`,
}, lang),
html: (opts: {
firstName: string
vehicleYear: number
vehicleMake: string
vehicleModel: string
companyName: string
startDate: Date
endDate: Date
totalDays: number
rateDisplay: string
totalDisplay: string
email: string
phone?: string
}, lang: Lang): string => {
const startStr = formatDate(opts.startDate, lang)
const endStr = formatDate(opts.endDate, lang)
const isRtl = lang === 'ar'
const l = t({
en: {
greeting: `Hi ${opts.firstName},`,
intro: 'Your reservation request has been received and is pending company confirmation.',
company: 'Company', pickup: 'Pick-up date', returnD: 'Return date',
duration: 'Duration', daily: 'Daily rate', total: 'Estimated total', days: 'day(s)',
footer: `The company will review your request and get in touch shortly. You may be contacted at ${opts.email}${opts.phone ? ` or ${opts.phone}` : ''}.`,
},
fr: {
greeting: `Bonjour ${opts.firstName},`,
intro: 'Votre demande de réservation a été reçue et est en attente de confirmation.',
company: 'Société', pickup: 'Date de prise en charge', returnD: 'Date de retour',
duration: 'Durée', daily: 'Tarif journalier', total: 'Total estimé', days: 'jour(s)',
footer: `La société examinera votre demande et vous contactera prochainement. Vous pouvez être contacté à ${opts.email}${opts.phone ? ` ou ${opts.phone}` : ''}.`,
},
ar: {
greeting: `مرحباً ${opts.firstName}،`,
intro: 'تم استلام طلب الحجز وهو في انتظار تأكيد الشركة.',
company: 'الشركة', pickup: 'تاريخ الاستلام', returnD: 'تاريخ الإرجاع',
duration: 'المدة', daily: 'السعر اليومي', total: 'الإجمالي التقديري', days: 'يوم',
footer: `ستراجع الشركة طلبك وستتواصل معك قريباً. يمكن التواصل معك على ${opts.email}${opts.phone ? ` أو ${opts.phone}` : ''}.`,
},
}, lang)
return `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
<h2 style="margin-bottom:4px">${l.greeting}</h2>
<p style="color:#57534e">${l.intro}</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<h3 style="margin-bottom:12px">${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel}</h3>
<p><strong>${l.company}:</strong> ${opts.companyName}</p>
<p><strong>${l.pickup}:</strong> ${startStr}</p>
<p><strong>${l.returnD}:</strong> ${endStr}</p>
<p><strong>${l.duration}:</strong> ${opts.totalDays} ${l.days}</p>
<p><strong>${l.daily}:</strong> ${opts.rateDisplay} MAD</p>
<p><strong>${l.total}:</strong> ${opts.totalDisplay} MAD</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<p style="color:#57534e;font-size:13px">${l.footer}</p>
</div>
`
},
text: (opts: {
firstName: string
vehicleYear: number
vehicleMake: string
vehicleModel: string
companyName: string
startDate: Date
endDate: Date
totalDays: number
rateDisplay: string
totalDisplay: string
}, lang: Lang): string => {
const startStr = formatDate(opts.startDate, lang)
const endStr = formatDate(opts.endDate, lang)
return t({
en: `Hi ${opts.firstName},\n\nYour reservation request for the ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} from ${opts.companyName} has been received.\n\nDates: ${startStr}${endStr}\nDuration: ${opts.totalDays} day(s)\nDaily rate: ${opts.rateDisplay} MAD\nEstimated total: ${opts.totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
fr: `Bonjour ${opts.firstName},\n\nVotre demande de réservation pour la ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} auprès de ${opts.companyName} a été reçue.\n\nDates : ${startStr}${endStr}\nDurée : ${opts.totalDays} jour(s)\nTarif journalier : ${opts.rateDisplay} MAD\nTotal estimé : ${opts.totalDisplay} MAD\n\nLa société confirmera votre demande prochainement.\n\nMerci !`,
ar: `مرحباً ${opts.firstName}،\n\nتم استلام طلب حجزك لسيارة ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} من ${opts.companyName}.\n\nالتواريخ: ${startStr}${endStr}\nالمدة: ${opts.totalDays} يوم\nالسعر اليومي: ${opts.rateDisplay} MAD\nالإجمالي التقديري: ${opts.totalDisplay} MAD\n\nستؤكد الشركة طلبك قريباً.\n\nشكراً!`,
}, lang)
},
}
// ─── Booking confirmed ────────────────────────────────────────────────────────
export const bookingConfirmedNotif = {
title: (lang: Lang) => t({
en: 'Booking Confirmed',
fr: 'Réservation confirmée',
ar: 'تم تأكيد الحجز',
}, lang),
body: (lang: Lang) => t({
en: 'Your booking has been confirmed.',
fr: 'Votre réservation a été confirmée.',
ar: 'تم تأكيد حجزك.',
}, lang),
}
// ─── Post-rental review request ───────────────────────────────────────────────
export const reviewRequestEmail = {
subject: (vehicleLabel: string, lang: Lang) => t({
en: `How was your rental? — ${vehicleLabel}`,
fr: `Comment s'est passée votre location ? — ${vehicleLabel}`,
ar: `كيف كانت تجربة الإيجار؟ — ${vehicleLabel}`,
}, lang),
html: (opts: {
firstName: string
companyName: string
vehicleLabel: string
reviewUrl: string
}, lang: Lang): string => {
const isRtl = lang === 'ar'
const l = t({
en: {
greeting: `Hi ${opts.firstName},`,
body1: `Thank you for renting with <strong>${opts.companyName}</strong>. We hope you enjoyed your <strong>${opts.vehicleLabel}</strong>.`,
body2: 'Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.',
cta: 'Leave a review',
disclaimer: 'This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.',
},
fr: {
greeting: `Bonjour ${opts.firstName},`,
body1: `Merci d'avoir loué chez <strong>${opts.companyName}</strong>. Nous espérons que vous avez apprécié votre <strong>${opts.vehicleLabel}</strong>.`,
body2: "Votre avis aide la société à s'améliorer et aide les autres clients à faire des choix éclairés. Cela ne prend que 30 secondes.",
cta: 'Laisser un avis',
disclaimer: "Ce lien est unique à votre réservation et ne peut être utilisé qu'une seule fois. Si vous n'avez pas loué de véhicule récemment, vous pouvez ignorer cet e-mail.",
},
ar: {
greeting: `مرحباً ${opts.firstName}،`,
body1: `شكراً لاختيار <strong>${opts.companyName}</strong>. نأمل أنك استمتعت بـ <strong>${opts.vehicleLabel}</strong>.`,
body2: 'تساعد ملاحظاتك الشركة على التحسين وتساعد العملاء الآخرين على اتخاذ قرارات مستنيرة. لا يستغرق الأمر سوى 30 ثانية.',
cta: 'اترك تقييماً',
disclaimer: 'هذا الرابط فريد لحجزك ولا يمكن استخدامه إلا مرة واحدة. إذا لم تستأجر مركبة مؤخراً، يمكنك تجاهل هذا البريد الإلكتروني.',
},
}, lang)
return `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
<h2 style="margin-bottom:4px">${l.greeting}</h2>
<p style="color:#57534e">${l.body1}</p>
<p style="color:#57534e">${l.body2}</p>
<div style="margin:32px 0;text-align:center">
<a href="${opts.reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
${l.cta}
</a>
</div>
<p style="color:#a8a29e;font-size:12px">${l.disclaimer}</p>
</div>
`
},
text: (opts: {
firstName: string
companyName: string
vehicleLabel: string
reviewUrl: string
}, lang: Lang): string => t({
en: `Hi ${opts.firstName},\n\nThank you for renting with ${opts.companyName}. We hope you enjoyed your ${opts.vehicleLabel}.\n\nLeave a review here (one-time link):\n${opts.reviewUrl}\n\nThank you!`,
fr: `Bonjour ${opts.firstName},\n\nMerci d'avoir loué chez ${opts.companyName}. Nous espérons que vous avez apprécié votre ${opts.vehicleLabel}.\n\nLaissez un avis ici (lien unique) :\n${opts.reviewUrl}\n\nMerci !`,
ar: `مرحباً ${opts.firstName}،\n\nشكراً لاختيار ${opts.companyName}. نأمل أنك استمتعت بـ ${opts.vehicleLabel}.\n\nاترك تقييماً هنا (رابط فريد):\n${opts.reviewUrl}\n\nشكراً!`,
}, lang),
}
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import { sanitizeAndFormat, toTitleCase, toTitleCaseAll, toLowerCase, toUpperCase, preserveOriginal, getMaxLength } from './inputValidation'
describe('toTitleCase', () => {
it('uppercases first letter and lowercases rest of each word', () => {
expect(toTitleCase('john doe')).toBe('John Doe')
expect(toTitleCase('MARIA')).toBe('Maria')
expect(toTitleCase('new york')).toBe('New York')
})
it('preserves numbers and special chars that survive sanitization', () => {
expect(toTitleCase('123 main st.')).toBe('123 Main St.')
})
it('handles single word', () => {
expect(toTitleCase('hello')).toBe('Hello')
})
})
describe('toTitleCaseAll', () => {
it('uppercases first letter of every word', () => {
expect(toTitleCaseAll('acme corporation ltd.')).toBe('Acme Corporation Ltd.')
expect(toTitleCaseAll('123 main street, apt 4b')).toBe('123 Main Street, Apt 4b')
})
})
describe('toLowerCase', () => {
it('lowercases all characters', () => {
expect(toLowerCase('John.Doe@Example.COM')).toBe('john.doe@example.com')
expect(toLowerCase('ALLCAPS')).toBe('allcaps')
})
})
describe('toUpperCase', () => {
it('uppercases letters, leaves numbers unchanged', () => {
expect(toUpperCase('abc-123')).toBe('ABC-123')
expect(toUpperCase('ab123cd')).toBe('AB123CD')
})
})
describe('preserveOriginal', () => {
it('returns the string unchanged', () => {
expect(preserveOriginal('SW1A 1AA')).toBe('SW1A 1AA')
expect(preserveOriginal('12345')).toBe('12345')
})
})
describe('sanitizeAndFormat', () => {
// ─── Title Case fields
it('formats name fields (title case, max 50)', () => {
expect(sanitizeAndFormat('john doe', 'name')).toBe('John Doe')
expect(sanitizeAndFormat('MARIA', 'name')).toBe('Maria')
})
it('formats streetAddress (title case, max 100)', () => {
expect(sanitizeAndFormat('123 main st.', 'streetAddress')).toBe('123 Main St')
expect(sanitizeAndFormat('elm street 42', 'streetAddress')).toBe('Elm Street 42')
})
it('formats city (title case, max 50)', () => {
expect(sanitizeAndFormat('new york', 'city')).toBe('New York')
})
it('formats pickupLocation and returnLocation', () => {
expect(sanitizeAndFormat('airport terminal 1', 'pickupLocation')).toBe('Airport Terminal 1')
expect(sanitizeAndFormat('downtown garage', 'returnLocation')).toBe('Downtown Garage')
})
it('formats country (title case)', () => {
expect(sanitizeAndFormat('united kingdom', 'country')).toBe('United Kingdom')
})
// ─── Title Case All fields
it('formats fullAddress (title case all, max 200)', () => {
expect(sanitizeAndFormat('123 main street, apt 4b', 'fullAddress')).toBe('123 Main Street, Apt 4b')
})
it('formats commercialName (title case all, max 100)', () => {
expect(sanitizeAndFormat('acme corporation', 'commercialName')).toBe('Acme Corporation')
})
it('formats legalCompanyName (title case all, max 100)', () => {
expect(sanitizeAndFormat('global rentals llc', 'legalCompanyName')).toBe('Global Rentals Llc')
})
// ─── Email
it('formats email (lowercase)', () => {
expect(sanitizeAndFormat('John.Doe@Example.COM', 'email')).toBe('john.doe@example.com')
})
// ─── Uppercase fields
it('formats licensePlate (uppercase, preserves hyphens)', () => {
expect(sanitizeAndFormat('abc-123', 'licensePlate')).toBe('ABC-123')
})
it('formats VIN (uppercase)', () => {
expect(sanitizeAndFormat('1hgbh41jxmn109186', 'vin')).toBe('1HGBH41JXMN109186')
})
it('formats passportNumber (uppercase)', () => {
expect(sanitizeAndFormat('ab123456', 'passportNumber')).toBe('AB123456')
})
it('formats driverLicenseNumber (uppercase, preserves hyphens)', () => {
expect(sanitizeAndFormat('d123-456-789', 'driverLicenseNumber')).toBe('D123-456-789')
})
it('formats licenseCategory (uppercase)', () => {
expect(sanitizeAndFormat('b', 'licenseCategory')).toBe('B')
})
// ─── Car fields
it('formats carMark (title case)', () => {
expect(sanitizeAndFormat('TOYOTA', 'carMark')).toBe('Toyota')
expect(sanitizeAndFormat('mercedes-benz', 'carMark')).toBe('Mercedes-Benz')
})
it('formats carModel (title case)', () => {
expect(sanitizeAndFormat('corolla', 'carModel')).toBe('Corolla')
})
it('formats carColor (title case)', () => {
expect(sanitizeAndFormat('midnight-blue', 'carColor')).toBe('Midnight-Blue')
})
// ─── ZIP Code (preserved)
it('preserves ZIP code format', () => {
expect(sanitizeAndFormat('12345', 'zipCode')).toBe('12345')
expect(sanitizeAndFormat('SW1A 1AA', 'zipCode')).toBe('SW1A 1AA')
})
// ─── Sanitization
it('removes disallowed characters', () => {
expect(sanitizeAndFormat('John!@#', 'name')).toBe('John')
})
it('strips disallowed characters including angle brackets', () => {
const result = sanitizeAndFormat('test<script>alert(1)</script>', 'name')
expect(result).toBe('Testscriptalertscript')
})
// ─── Truncation
it('truncates to max length', () => {
const longName = 'A'.repeat(60)
expect(sanitizeAndFormat(longName, 'name')).toHaveLength(50)
})
it('does not truncate strings within limit', () => {
expect(sanitizeAndFormat('John', 'name')).toHaveLength(4)
})
})
describe('getMaxLength', () => {
it('returns correct max lengths', () => {
expect(getMaxLength('name')).toBe(50)
expect(getMaxLength('streetAddress')).toBe(100)
expect(getMaxLength('fullAddress')).toBe(200)
expect(getMaxLength('email')).toBe(100)
expect(getMaxLength('licensePlate')).toBe(30)
expect(getMaxLength('zipCode')).toBe(10)
expect(getMaxLength('carMark')).toBe(30)
})
})
+136
View File
@@ -0,0 +1,136 @@
/**
* User Input Validation and Formatting Library
*
* Implements the rules defined in docs/input_validation_plan.md.
* Provides formatting functions and a Zod-integrated pipeline that:
* 1. Removes disallowed characters
* 2. Trims leading/trailing whitespace
* 3. Validates format (email)
* 4. Applies case transformation
* 5. Truncates to max length
*/
// ─── Character class definitions ───────────────────────────────
const LETTERS_NUMBERS = 'a-zA-Z0-9'
const LETTERS_NUMBERS_HYPHEN = `${LETTERS_NUMBERS}\\-`
const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-'
const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/`
const EXTENDED_TEXT = `${BASE_TEXT},`
// ─── Field type definitions ────────────────────────────────────
export type FieldType =
| 'name' | 'nationality' | 'streetAddress' | 'city'
| 'pickupLocation' | 'returnLocation' | 'country'
| 'fullAddress' | 'commercialName' | 'legalCompanyName'
| 'email'
| 'licensePlate' | 'vin' | 'cin' | 'passportNumber'
| 'internationalPermitNumber' | 'driverLicenseNumber'
| 'licenseCategory' | 'iceNumber' | 'commercialRegistry'
| 'carMark' | 'carModel' | 'carColor'
| 'zipCode'
interface FieldConfig {
maxLength: number
allowedPattern: RegExp
transform: (value: string) => string
}
// ─── Formatting functions ──────────────────────────────────────
/** Uppercase first letter of each word, lowercase the rest */
export function toTitleCase(str: string): string {
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
}
/** Uppercase first letter of every word */
export function toTitleCaseAll(str: string): string {
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
}
export function toLowerCase(str: string): string {
return str.toLowerCase()
}
/** Uppercase letters only, leave numbers unchanged */
export function toUpperCase(str: string): string {
return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase())
}
export function preserveOriginal(str: string): string {
return str
}
// ─── Field configuration ───────────────────────────────────────
const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
// Group 1: Title Case
name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
// Group 2: Title Case All
fullAddress: { maxLength: 200, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
// Group 3: Lowercase — email
email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
// Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
vin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
cin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
passportNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
licenseCategory: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
iceNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
commercialRegistry: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
// Group 5: Car fields
carMark: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
carModel: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
carColor: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
// Group 6: Preserved (spaces for UK postcodes like "SW1A 1AA")
zipCode: { maxLength: 10, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: preserveOriginal },
}
// ─── Public API ────────────────────────────────────────────────
/** Full processing pipeline: sanitize → trim → format → truncate */
export function sanitizeAndFormat(value: string, fieldType: FieldType): string {
const config = FIELD_CONFIGS[fieldType]
// Step 1: Remove disallowed characters
let sanitized = ''
for (const char of value) {
if (config.allowedPattern.test(char)) {
sanitized += char
}
}
// Step 2: Trim
sanitized = sanitized.trim()
// Step 3: Apply case transformation
const formatted = config.transform(sanitized)
// Step 4: Truncate
if (formatted.length > config.maxLength) {
return formatted.slice(0, config.maxLength)
}
return formatted
}
/** Get the max length for a given field type */
export function getMaxLength(fieldType: FieldType): number {
return FIELD_CONFIGS[fieldType].maxLength
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { isDatabaseUnavailableError } from './isDatabaseUnavailable'
describe('isDatabaseUnavailableError', () => {
it('detects Prisma P1001 connection failures', () => {
expect(isDatabaseUnavailableError({ code: 'P1001' })).toBe(true)
})
it('detects database reachability failures by message', () => {
expect(isDatabaseUnavailableError({ message: "Can't reach database server at postgres:5432" })).toBe(true)
})
it('rejects unrelated, null, and primitive errors', () => {
expect(isDatabaseUnavailableError({ code: 'P2002' })).toBe(false)
expect(isDatabaseUnavailableError(new Error('network hiccup'))).toBe(false)
expect(isDatabaseUnavailableError(null)).toBe(false)
expect(isDatabaseUnavailableError('P1001')).toBe(false)
})
})
+5
View File
@@ -0,0 +1,5 @@
export function isDatabaseUnavailableError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const candidate = error as { code?: string; message?: string }
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from '@rentaldrivego/database/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
+10
View File
@@ -0,0 +1,10 @@
import Redis from 'ioredis'
export const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
maxRetriesPerRequest: 3,
retryStrategy: (times) => Math.min(times * 50, 2000),
})
redis.on('error', (err) => {
console.error('[Redis] Connection error:', err.message)
})
+62
View File
@@ -0,0 +1,62 @@
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertStorageConfiguration,
deleteImage,
getPrivateStorageRoot,
getProtectedCustomerLicenseImageUrl,
getPublicStorageRoot,
getStorageRoot,
resolveStoredFilePath,
uploadImage,
} from './storage'
const originalEnv = { ...process.env }
describe('storage', () => {
afterEach(() => {
process.env = { ...originalEnv }
})
it('uses FILE_STORAGE_ROOT when configured and builds protected customer license URLs from dashboard URL', () => {
process.env.FILE_STORAGE_ROOT = '/tmp/rdg-storage'
process.env.DASHBOARD_URL = 'https://dashboard.rentaldrivego.test/dashboard/'
expect(getStorageRoot()).toBe('/tmp/rdg-storage')
expect(getPublicStorageRoot()).toBe('/tmp/rdg-storage/public')
expect(getPrivateStorageRoot()).toBe('/tmp/rdg-storage/private')
expect(getProtectedCustomerLicenseImageUrl('customer_1')).toBe('https://dashboard.rentaldrivego.test/dashboard/api/v1/customers/customer_1/license-image')
})
it('rejects implicit in-app storage in production', () => {
delete process.env.FILE_STORAGE_ROOT
process.env.NODE_ENV = 'production'
expect(() => assertStorageConfiguration()).toThrow('FILE_STORAGE_ROOT must be set in production')
})
it('uploads, resolves, and deletes files under the configured storage root', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-storage-'))
process.env.FILE_STORAGE_ROOT = root
process.env.API_URL = 'https://api.rentaldrivego.test/'
const url = await uploadImage(Buffer.from('image-bytes'), 'customers/licenses', 'customer_1')
expect(url).toBe('https://api.rentaldrivego.test/storage/customers/licenses/customer_1.jpg')
const filePath = resolveStoredFilePath(url)
expect(filePath).toBe(path.join(root, 'private/customers/licenses/customer_1.jpg'))
expect(fs.readFileSync(filePath!, 'utf8')).toBe('image-bytes')
await deleteImage(url)
expect(fs.existsSync(filePath!)).toBe(false)
})
it('ignores non-storage URLs when resolving or deleting files', async () => {
process.env.FILE_STORAGE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-storage-'))
expect(resolveStoredFilePath('https://cdn.test/not-storage/file.jpg')).toBeNull()
await expect(deleteImage('https://cdn.test/not-storage/file.jpg')).resolves.toBeUndefined()
})
})
+142
View File
@@ -0,0 +1,142 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const APP_PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..')
const APP_SOURCE_ROOT = path.join(APP_PACKAGE_ROOT, 'src')
const APP_DIST_ROOT = path.join(APP_PACKAGE_ROOT, 'dist')
const DEFAULT_STORAGE_ROOT = path.join(APP_PACKAGE_ROOT, 'storage')
const LEGACY_STORAGE_ROOTS = [
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
]
export type StorageVisibility = 'public' | 'private'
export function getStorageRoot(): string {
return process.env.FILE_STORAGE_ROOT
? path.resolve(process.env.FILE_STORAGE_ROOT)
: DEFAULT_STORAGE_ROOT
}
export function getPublicStorageRoot(): string {
return path.join(getStorageRoot(), 'public')
}
export function getPrivateStorageRoot(): string {
return path.join(getStorageRoot(), 'private')
}
export function getLegacyStorageRoots(): string[] {
return LEGACY_STORAGE_ROOTS
}
function isWithinPath(targetPath: string, parentPath: string): boolean {
const relative = path.relative(parentPath, targetPath)
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
}
export function assertStorageConfiguration(): string {
const storageRoot = getStorageRoot()
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
}
if (process.env.NODE_ENV === 'production') {
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
if (invalidRoot) {
throw new Error(
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
)
}
}
return storageRoot
}
function ensureStorageRoot(visibility: StorageVisibility): string {
assertStorageConfiguration()
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
fs.mkdirSync(root, { recursive: true })
return root
}
function inferVisibility(folder: string): StorageVisibility {
const normalized = folder.replace(/\\/g, '/')
if (/\/customers\//.test(`/${normalized}/`)) return 'private'
if (/\/licenses?\//.test(`/${normalized}/`)) return 'private'
if (/\/contracts?\//.test(`/${normalized}/`)) return 'private'
if (/\/documents?\//.test(`/${normalized}/`)) return 'private'
if (/\/inspections?\//.test(`/${normalized}/`)) return 'private'
if (/\/internal\//.test(`/${normalized}/`)) return 'private'
return 'public'
}
function getApiBase(): string {
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
}
function getDashboardBase(): string {
return (
process.env.DASHBOARD_URL ??
process.env.NEXT_PUBLIC_DASHBOARD_URL ??
'http://localhost:3000/dashboard'
).replace(/\/$/, '')
}
function getStorageRelativePath(imageUrl: string): string | null {
const marker = '/storage/'
const idx = imageUrl.indexOf(marker)
if (idx === -1) return null
return imageUrl.slice(idx + marker.length)
}
export function getProtectedCustomerLicenseImageUrl(customerId: string): string {
return `${getDashboardBase()}/api/v1/customers/${customerId}/license-image`
}
export async function uploadImage(
buffer: Buffer,
folder: string,
publicId?: string,
visibility: StorageVisibility = inferVisibility(folder),
): Promise<string> {
const storageRoot = ensureStorageRoot(visibility)
const folderPath = path.join(storageRoot, folder)
if (!isWithinPath(folderPath, storageRoot)) {
throw new Error('Upload path escapes storage root')
}
fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
? `${publicId}.jpg`
: `${crypto.randomBytes(16).toString('hex')}.jpg`
fs.writeFileSync(path.join(folderPath, filename), buffer)
return `${getApiBase()}/storage/${folder}/${filename}`
}
export function resolveStoredFilePath(imageUrl: string): string | null {
const relative = getStorageRelativePath(imageUrl)
if (!relative) return null
const roots = [getPublicStorageRoot(), getPrivateStorageRoot(), ...getLegacyStorageRoots(), assertStorageConfiguration()]
for (const root of roots) {
const filePath = path.join(root, relative)
if (!isWithinPath(filePath, root)) continue
if (fs.existsSync(filePath)) {
return filePath
}
}
return null
}
export async function deleteImage(imageUrl: string): Promise<void> {
const filePath = resolveStoredFilePath(imageUrl)
if (filePath && fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
}
+155
View File
@@ -0,0 +1,155 @@
import { z } from 'zod'
import { sanitizeAndFormat, getMaxLength } from './inputValidation'
import type { FieldType } from './inputValidation'
// ─── Regex constants ───────────────────────────────────────────
const GLOBAL_ALLOWED = /^[a-zA-Z0-9@\-/\s]*$/
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
/** Morocco phone: (+212|00212|0) + 5/6/7 + 8 digits (optional spaces) */
const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/
// ─── Phone sanitization ───────────────────────────────────────
function sanitizePhone(raw: string): string {
let out = ''
for (const ch of raw) {
if (/[\d\s+]/.test(ch)) out += ch
}
return out.trim()
}
// ─── Required fields ───────────────────────────────────────────
function applyFieldRules(fieldType: FieldType) {
const maxLength = getMaxLength(fieldType)
return z
.string()
.min(1, { message: 'This field is required' })
.trim()
.transform((val: string) => sanitizeAndFormat(val, fieldType))
.pipe(
z.string().refine(
(val: string) => val.length <= maxLength,
{ message: 'Maximum ' + maxLength + ' characters allowed' }
)
)
}
// ─── Optional fields ───────────────────────────────────────────
function applyOptionalFieldRules(fieldType: FieldType) {
const maxLength = getMaxLength(fieldType)
return z
.string()
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
return sanitizeAndFormat(val, fieldType)
})
.pipe(
z.string().optional().refine(
(val) => val === undefined || val.length <= maxLength,
{ message: 'Maximum ' + maxLength + ' characters allowed' }
)
)
}
// ─── Exported helpers ──────────────────────────────────────────
export function textField(fieldType: FieldType) {
return applyFieldRules(fieldType)
}
export function optionalTextField(fieldType: FieldType) {
return applyOptionalFieldRules(fieldType)
}
export function titleCaseAllField(fieldType: FieldType) {
return applyFieldRules(fieldType)
}
export function optionalTitleCaseAllField(fieldType: FieldType) {
return applyOptionalFieldRules(fieldType)
}
export function upperField(fieldType: FieldType) {
return applyFieldRules(fieldType)
}
export function optionalUpperField(fieldType: FieldType) {
return applyOptionalFieldRules(fieldType)
}
export function carTextField(fieldType: FieldType) {
return applyFieldRules(fieldType)
}
export function optionalCarTextField(fieldType: FieldType) {
return applyOptionalFieldRules(fieldType)
}
export function zipField() {
return applyFieldRules('zipCode')
}
export function optionalZipField() {
return applyOptionalFieldRules('zipCode')
}
// ─── Email fields ──────────────────────────────────────────────
export function emailField() {
return z
.string()
.min(1, { message: 'Email is required' })
.trim()
.transform((val: string) => sanitizeAndFormat(val, 'email'))
.pipe(
z.string().refine(
(val: string) => EMAIL_REGEX.test(val),
{ message: 'Please enter a valid email address' }
)
)
}
export function optionalEmailField() {
return z
.string()
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
return sanitizeAndFormat(val, 'email')
})
.pipe(
z.string().optional().refine(
(val) => val === undefined || EMAIL_REGEX.test(val),
{ message: 'Please enter a valid email address' }
)
)
}
// ─── Phone fields ──────────────────────────────────────────────
export function phoneField() {
return z
.string()
.min(1, { message: 'Phone number is required' })
.trim()
.transform((val: string) => sanitizePhone(val))
.pipe(
z.string().refine(
(val: string) => MA_PHONE_REGEX.test(val),
{ message: 'Please enter a valid Morocco phone number' }
)
)
}
export function optionalPhoneField() {
return z
.string()
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
return sanitizePhone(val)
})
.pipe(
z.string().optional().refine(
(val) => val === undefined || MA_PHONE_REGEX.test(val),
{ message: 'Please enter a valid Morocco phone number' }
)
)
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { getCookie, sendForbidden, sendPaymentRequired, sendUnauthorized } from './authHelpers'
function responseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('authHelpers', () => {
it('extracts and decodes a cookie value by name', () => {
const req = {
headers: {
cookie: 'theme=dark; employee_session=abc%20123%3D; other=value',
},
} as Request
expect(getCookie(req, 'employee_session')).toBe('abc 123=')
})
it('returns null when the cookie header or requested cookie is missing', () => {
expect(getCookie({ headers: {} } as Request, 'employee_session')).toBeNull()
expect(getCookie({ headers: { cookie: 'theme=dark' } } as Request, 'employee_session')).toBeNull()
})
it('sends a uniform unauthorized response', () => {
const res = responseStub()
sendUnauthorized(res, 'invalid_token', 'Invalid token')
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token', statusCode: 401 })
})
it('sends forbidden responses with optional metadata', () => {
const res = responseStub()
sendForbidden(res, 'forbidden', 'Nope', { requiredRole: 'OWNER' })
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'Nope',
statusCode: 403,
requiredRole: 'OWNER',
})
})
it('sends payment-required responses with optional metadata', () => {
const res = responseStub()
sendPaymentRequired(res, 'subscription_suspended', 'Pay up', { billingUrl: '/billing' })
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'subscription_suspended',
message: 'Pay up',
statusCode: 402,
billingUrl: '/billing',
})
})
})
+45
View File
@@ -0,0 +1,45 @@
import { Request, Response } from 'express'
/**
* Sends a uniform auth-error JSON response.
* All auth middleware must go through this function so the shape is identical
* to what the errorMiddleware produces for AppError instances.
*/
export function sendUnauthorized(res: Response, error: string, message: string) {
return res.status(401).json({ error, message, statusCode: 401 })
}
export function getCookie(req: Request, name: string): string | null {
const cookieHeader = req.headers.cookie
if (!cookieHeader) return null
for (const chunk of cookieHeader.split(';')) {
const [rawName, ...rawValue] = chunk.trim().split('=')
if (rawName === name) {
return decodeURIComponent(rawValue.join('='))
}
}
return null
}
export function getBearerToken(req: Request): string | null {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) return null
const token = authHeader.slice(7).trim()
return token.length > 0 ? token : null
}
export function getAuthToken(req: Request, cookieName?: string): string | null {
// Prefer HttpOnly cookies over bearer tokens so stale script-readable tokens
// cannot shadow a valid server-managed session during migration.
return (cookieName ? getCookie(req, cookieName) : null) ?? getBearerToken(req)
}
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
return res.status(403).json({ error, message, statusCode: 403, ...extra })
}
export function sendPaymentRequired(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
return res.status(402).json({ error, message, statusCode: 402, ...extra })
}
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const createdLimiters: any[] = []
vi.mock('express-rate-limit', () => ({
default: vi.fn((config: any) => {
createdLimiters.push(config)
return config
}),
ipKeyGenerator: vi.fn((ip: string) => `ip:${ip}`),
}))
vi.mock('../security/tokens', () => ({
verifyAnyActorToken: vi.fn((token: string) => {
if (token === 'employee-token') return { type: 'employee', sub: 'employee_1' }
if (token === 'renter-token') return { type: 'renter', sub: 'renter_1' }
throw new Error('Invalid actor token')
}),
}))
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
describe('rateLimiter middleware configuration', () => {
beforeEach(() => {
createdLimiters.length = 0
vi.resetModules()
})
it('configures auth limiter to count failed attempts only', async () => {
const { authLimiter } = await import('./rateLimiter')
expect(authLimiter.max).toBe(20)
expect(authLimiter.windowMs).toBe(15 * 60 * 1000)
expect(authLimiter.skipSuccessfulRequests).toBe(true)
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
expect(rateLimit).toHaveBeenCalled()
})
it('keys general API limits by verified actor identity before falling back to request context', async () => {
const { apiLimiter } = await import('./rateLimiter')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { cookie: 'theme=dark; employee_session=employee-token' },
companyId: 'company_1',
} as any)).toBe('ip:203.0.113.10:employee:employee_1')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { authorization: 'Bearer renter-token' },
renterId: 'legacy_renter_context',
} as any)).toBe('ip:203.0.113.10:renter:renter_1')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { authorization: 'Bearer invalid-token' },
companyId: 'company_1',
} as any)).toBe('ip:203.0.113.10:company_1')
expect(apiLimiter.keyGenerator({ ip: '203.0.113.10', headers: {}, renterId: 'renter_1' } as any)).toBe('ip:203.0.113.10:renter_1')
expect(ipKeyGenerator).toHaveBeenCalledWith('203.0.113.10')
})
it('uses tighter public and admin limits with explicit 429 payloads', async () => {
const { publicLimiter, adminLimiter } = await import('./rateLimiter')
expect(publicLimiter.max).toBe(60)
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
expect(adminLimiter.max).toBe(100)
expect(adminLimiter.message.message).toBe('Too many admin requests')
})
})
+126
View File
@@ -0,0 +1,126 @@
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { Request } from 'express'
import { verifyAnyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
const SESSION_COOKIE_NAMES = [
getSessionCookieName('admin'),
getSessionCookieName('employee'),
getSessionCookieName('renter'),
]
function readCookie(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return null
for (const chunk of cookieHeader.split(';')) {
const [rawName, ...rawValue] = chunk.trim().split('=')
if (rawName === name) return decodeURIComponent(rawValue.join('='))
}
return null
}
function getBearerToken(req: Request): string | null {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) return null
const token = authHeader.slice(7).trim()
return token || null
}
function getRequestToken(req: Request): string | null {
const cookieHeader = req.headers.cookie
for (const name of SESSION_COOKIE_NAMES) {
const token = readCookie(cookieHeader, name)
if (token) return token
}
return getBearerToken(req)
}
function getAuthenticatedActorKey(req: Request): string | null {
const token = getRequestToken(req)
if (!token) return null
try {
const payload = verifyAnyActorToken(token)
return `${payload.type}:${payload.sub}`
} catch {
return null
}
}
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
// attempts count toward the cap.
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: 'draft-7',
legacyHeaders: false,
skipSuccessfulRequests: true,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
})
// Standard limiter for general authenticated API endpoints
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 120,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
const companyId = (req as any).companyId ?? ''
const renterId = (req as any).renterId ?? ''
return `${ip}:${actorKey || companyId || renterId || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Limiter for public marketplace and site endpoints (no auth)
export const publicLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Tight limiter for admin endpoints
export const adminLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
// Applied after authentication so limits can include actor identity rather than
// pretending every employee behind the same NAT is the same organism.
export const actorLimiter = rateLimit({
windowMs: 60 * 1000,
max: 240,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
const companyId = (req as any).companyId ?? ''
const employeeId = (req as any).employee?.id ?? ''
const renterId = (req as any).renterId ?? ''
const adminId = (req as any).admin?.id ?? ''
return `${ip}:${companyId}:${actorKey || employeeId || renterId || adminId || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Authenticated rate limit exceeded', statusCode: 429 },
})
+10
View File
@@ -0,0 +1,10 @@
import crypto from 'crypto'
import type { Request, Response, NextFunction } from 'express'
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
const incoming = req.headers['x-request-id']
const requestId = Array.isArray(incoming) ? incoming[0] : incoming
req.requestId = requestId && requestId.length <= 128 ? requestId : `req_${crypto.randomUUID()}`
res.setHeader('X-Request-Id', req.requestId)
next()
}
+152
View File
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
adminUser: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireAdminAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects requests without an admin bearer token', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid token signatures', async () => {
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
const req = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
})
it('rejects non-admin token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive admin accounts', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches the admin record for valid admin tokens', async () => {
const admin = { id: 'admin_1', isActive: true, role: 'SUPPORT', totpEnabled: true }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(prisma.adminUser.findUnique).toHaveBeenCalledWith({ where: { id: 'admin_1' } })
expect(req.admin).toEqual(admin)
expect(next).toHaveBeenCalledTimes(1)
})
it('blocks non-enrolled admins from privileged routes', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
expect(next).not.toHaveBeenCalled()
})
})
describe('requireAdminRole middleware', () => {
it('requires requireAdminAuth to run first', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('SUPPORT' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('blocks admins below the required rank', () => {
const req = { admin: { role: 'VIEWER' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('FINANCE' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'This action requires the FINANCE role or higher',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('allows admins at or above the required rank', () => {
const req = { admin: { role: 'ADMIN' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('SUPPORT' as any)(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
+94
View File
@@ -0,0 +1,94 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { AdminRole } from '@rentaldrivego/database'
import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: 5,
ADMIN: 4,
SUPPORT: 3,
FINANCE: 2,
VIEWER: 1,
}
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
'/auth/me',
'/auth/logout',
'/auth/2fa/setup',
'/auth/2fa/verify',
])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
}
/**
* Requires a valid admin session token.
*
* Guarantees on success:
* req.admin — the full AdminUser record
*/
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
const token = getAuthToken(req, getSessionCookieName('admin'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
let payload: { sub: string; type: string; last2faAt?: number }
try {
payload = verifyActorToken(token, 'admin')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
}
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
if (!admin || !admin.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
}
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
}
req.admin = admin
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
next()
}
/**
* Requires the authenticated admin to have at least `minimumRole`.
* Must be applied after `requireAdminAuth`.
*/
export function requireAdminRole(minimumRole: AdminRole) {
return (req: Request, res: Response, next: NextFunction) => {
const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
const rank = ROLE_RANK[admin.role] ?? 0
const required = ROLE_RANK[minimumRole] ?? 99
if (rank < required) {
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
}
next()
}
}
export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunction) {
const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
if (!admin.totpEnabled) {
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
}
const last2faAt = req.adminAuthLast2faAt
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
}
next()
}
+159
View File
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { generateCompanyApiKey } from '../security/apiKeys'
vi.mock('../lib/prisma', () => ({
prisma: {
companyApiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { requireApiKey } from './requireApiKey'
function createResponseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireApiKey middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects requests with no x-api-key header', async () => {
const req = { headers: {} } as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'missing_api_key',
message: 'API key required in x-api-key header',
statusCode: 401,
})
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
})
it('rejects malformed API keys before database lookup', async () => {
const req = { headers: { 'x-api-key': 'bad-key' } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'invalid_api_key',
message: 'Invalid API key',
statusCode: 401,
})
expect(next).not.toHaveBeenCalled()
})
it('rejects unknown API key prefixes', async () => {
const generated = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue(null)
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.findUnique).toHaveBeenCalledWith({
where: { prefix: generated.prefix },
include: { company: true },
})
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'invalid_api_key',
message: 'Invalid API key',
statusCode: 401,
})
expect(next).not.toHaveBeenCalled()
})
it('rejects revoked API keys', async () => {
const generated = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: 'company_1',
prefix: generated.prefix,
keyHash: generated.keyHash,
revokedAt: new Date('2026-06-01T00:00:00.000Z'),
company: { id: 'company_1', name: 'Atlas Cars' },
})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('rejects API keys whose secret does not match the stored hash', async () => {
const generated = generateCompanyApiKey()
const other = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: 'company_1',
prefix: generated.prefix,
keyHash: other.keyHash,
revokedAt: null,
company: { id: 'company_1', name: 'Atlas Cars' },
})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('attaches company context, updates lastUsedAt, and calls next for valid API keys', async () => {
const generated = generateCompanyApiKey()
const company = { id: 'company_1', name: 'Atlas Cars' }
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: company.id,
prefix: generated.prefix,
keyHash: generated.keyHash,
revokedAt: null,
company,
})
vi.mocked((prisma as any).companyApiKey.update).mockResolvedValue({})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.update).toHaveBeenCalledWith({
where: { id: 'key_1' },
data: { lastUsedAt: expect.any(Date) },
})
expect(req.company).toEqual(company)
expect(req.companyId).toBe('company_1')
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).not.toHaveBeenCalled()
})
})
+35
View File
@@ -0,0 +1,35 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getApiKeyPrefix, hashApiKey, timingSafeEqualHex } from '../security/apiKeys'
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string | undefined
if (!apiKey) {
return res.status(401).json({ error: 'missing_api_key', message: 'API key required in x-api-key header', statusCode: 401 })
}
const prefix = getApiKeyPrefix(apiKey)
if (!prefix) {
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
}
const keyRecord = await (prisma as any).companyApiKey.findUnique({
where: { prefix },
include: { company: true },
})
const hashed = hashApiKey(apiKey)
if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) {
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
}
await (prisma as any).companyApiKey.update({
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
})
req.company = keyRecord.company
req.companyId = keyRecord.companyId
next()
}
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
employee: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth, requireCompanyDocumentAuth } from './requireCompanyAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireCompanyAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects missing bearer tokens', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid tokens', async () => {
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
const req = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects non-employee token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive or missing employees', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue({ id: 'emp_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Employee account not found or inactive', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches employee and company context for active employees', async () => {
const employee = { id: 'emp_1', companyId: 'company_1', isActive: true, company: { id: 'company_1', name: 'Atlas' } }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(prisma.employee.findUnique).toHaveBeenCalledWith({ where: { id: 'emp_1' }, include: { company: true } })
expect(req.employee).toEqual(employee)
expect(req.company).toEqual(employee.company)
expect(req.companyId).toBe('company_1')
expect(next).toHaveBeenCalledTimes(1)
})
it('accepts employee_session cookies for document routes', async () => {
const employee = { id: 'emp_2', companyId: 'company_2', isActive: true, company: { id: 'company_2' } }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_2', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
const req = { headers: { cookie: 'employee_session=cookie-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyDocumentAuth(req, res, next)
expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret', {
algorithms: ['HS256'],
issuer: 'rentaldrivego-api',
audience: 'employee',
})
expect(req.companyId).toBe('company_2')
expect(next).toHaveBeenCalledTimes(1)
})
})
+49
View File
@@ -0,0 +1,49 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getAuthToken, sendUnauthorized } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { actorLimiter } from './rateLimiter'
/**
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
*
* Guarantees on success:
* req.employee — full employee record (with company relation)
* req.company — the employee's company
* req.companyId — string shorthand for req.company.id
*/
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction) {
const token = getAuthToken(req, getSessionCookieName('employee'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
let payload: { sub: string; type: string }
try {
payload = verifyActorToken(token, 'employee')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
}
const employee = await prisma.employee.findUnique({
where: { id: payload.sub },
include: { company: true },
})
if (!employee || !employee.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Employee account not found or inactive')
}
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
return actorLimiter(req, res, next)
}
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
return authenticateCompanyRequest(req, res, next)
}
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
return authenticateCompanyRequest(req, res, next)
}
@@ -0,0 +1,22 @@
import type { Request, Response, NextFunction } from 'express'
import { sendForbidden, sendUnauthorized } from './authHelpers'
import type { EmployeeRole } from '@rentaldrivego/database'
import { CompanyPolicy, type CompanyPolicyAction } from '../security/policies/companyPolicy'
export function requireCompanyPolicy(action: CompanyPolicyAction) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
const allowedRoles: readonly EmployeeRole[] = CompanyPolicy[action]
if (!allowedRoles.includes(employee.role)) {
return sendForbidden(res, 'forbidden', 'You do not have permission to perform this action', {
action,
allowedRoles,
yourRole: employee.role,
})
}
next()
}
}
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
renter: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { optionalRenterAuth, requireRenterAuth } from './requireRenterAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireRenterAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects missing bearer tokens', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid tokens and wrong token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
expect(prisma.renter.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive renters', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches renterId for active renters', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: true } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(req.renterId).toBe('renter_1')
expect(next).toHaveBeenCalledTimes(1)
})
it('optional auth never blocks missing or invalid tokens', async () => {
const noTokenReq = { headers: {} } as Request
const invalidReq = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
await optionalRenterAuth(noTokenReq, res, next)
await optionalRenterAuth(invalidReq, res, next)
expect(next).toHaveBeenCalledTimes(2)
})
it('optional auth attaches renterId only for renter tokens', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_2', type: 'renter' } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await optionalRenterAuth(req, res, next)
expect(req.renterId).toBe('renter_2')
expect(next).toHaveBeenCalledTimes(1)
})
})
+54
View File
@@ -0,0 +1,54 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getAuthToken, sendUnauthorized } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { actorLimiter } from './rateLimiter'
/**
* Requires a valid renter Bearer token.
*
* Guarantees on success:
* req.renterId — the authenticated renter's id
*/
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
const token = getAuthToken(req, getSessionCookieName('renter'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
let payload: { sub: string; type: string }
try {
payload = verifyActorToken(token, 'renter')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
}
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
if (!renter || !renter.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
}
req.renterId = renter.id
return actorLimiter(req, res, next)
}
/**
* Optionally extracts renter identity from a Bearer token.
* Never blocks the request — invalid or absent tokens are silently ignored.
*
* Sets on success:
* req.renterId — the authenticated renter's id (if token is valid)
*/
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
const token = getAuthToken(req, getSessionCookieName('renter'))
if (!token) return next()
try {
const payload = verifyActorToken(token, 'renter')
req.renterId = payload.sub
} catch {
// Optional — silently ignore invalid tokens
}
next()
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { requireRole } from './requireRole'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireRole middleware', () => {
it('rejects requests when company auth did not attach an employee', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('AGENT' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects employees below the required role and includes role context', () => {
const req = { employee: { role: 'AGENT' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('MANAGER' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'This action requires the MANAGER role or higher',
statusCode: 403,
requiredRole: 'MANAGER',
yourRole: 'AGENT',
})
expect(next).not.toHaveBeenCalled()
})
it('allows employees at or above the required role', () => {
const req = { employee: { role: 'OWNER' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('MANAGER' as any)(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
+32
View File
@@ -0,0 +1,32 @@
import { Request, Response, NextFunction } from 'express'
import { EmployeeRole } from '@rentaldrivego/database'
import { sendUnauthorized, sendForbidden } from './authHelpers'
const ROLE_RANK: Record<EmployeeRole, number> = {
OWNER: 3,
MANAGER: 2,
AGENT: 1,
}
/**
* Requires the authenticated employee to have at least `minimumRole`.
* Must be applied after `requireCompanyAuth`.
*/
export function requireRole(minimumRole: EmployeeRole) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
const employeeRank = ROLE_RANK[employee.role] ?? 0
const requiredRank = ROLE_RANK[minimumRole] ?? 99
if (employeeRank < requiredRank) {
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`, {
requiredRole: minimumRole,
yourRole: employee.role,
})
}
next()
}
}
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { requireSubscription } from './requireSubscription'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireSubscription middleware', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
})
it('requires tenant/company context first', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('blocks suspended companies with a billing URL', () => {
const req = { company: { status: 'SUSPENDED' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'subscription_suspended',
message: 'Your account has been suspended. Please contact support or renew your subscription.',
statusCode: 402,
billingUrl: 'https://dashboard.example.test/billing',
})
expect(next).not.toHaveBeenCalled()
})
it('blocks pending companies with setup guidance', () => {
const req = { company: { status: 'PENDING' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: 'subscription_pending',
message: 'Your account is pending activation. Please complete your subscription setup.',
}))
expect(next).not.toHaveBeenCalled()
})
it('allows active companies through', () => {
const req = { company: { status: 'ACTIVE' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
+29
View File
@@ -0,0 +1,29 @@
import { Request, Response, NextFunction } from 'express'
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
/**
* Blocks requests for companies with lapsed or unactivated subscriptions.
* Must be applied after `requireTenant`.
*
* Guarantees on success:
* req.company.status is not SUSPENDED or PENDING
*/
export function requireSubscription(req: Request, res: Response, next: NextFunction) {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (BLOCKED_STATUSES.includes(company.status)) {
return sendPaymentRequired(
res,
`subscription_${company.status.toLowerCase()}`,
company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.',
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` },
)
}
next()
}
+67
View File
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('../lib/prisma', () => ({
prisma: {
company: { findUnique: vi.fn() },
},
}))
import { prisma } from '../lib/prisma'
import { requireTenant } from './requireTenant'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireTenant middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fails fast when company auth did not set companyId', async () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'unauthenticated',
message: 'Tenant context missing — requireCompanyAuth must run first',
statusCode: 401,
})
expect(prisma.company.findUnique).not.toHaveBeenCalled()
})
it('rejects company ids that no longer exist', async () => {
vi.mocked(prisma.company.findUnique).mockResolvedValue(null)
const req = { companyId: 'company_missing' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(prisma.company.findUnique).toHaveBeenCalledWith({ where: { id: 'company_missing' } })
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'company_not_found', message: 'Company not found', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches the fresh company record and continues', async () => {
const company = { id: 'company_1', status: 'ACTIVE' }
vi.mocked(prisma.company.findUnique).mockResolvedValue(company as any)
const req = { companyId: 'company_1' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(req.company).toEqual(company)
expect(next).toHaveBeenCalledTimes(1)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { sendUnauthorized } from './authHelpers'
/**
* Loads the company record and validates it exists.
* Must be applied after `requireCompanyAuth`.
*
* Guarantees on success:
* req.company — full Company record
* req.companyId — string id (already set by requireCompanyAuth)
*/
export async function requireTenant(req: Request, res: Response, next: NextFunction) {
if (!req.companyId) {
return sendUnauthorized(res, 'unauthenticated', 'Tenant context missing — requireCompanyAuth must run first')
}
const company = await prisma.company.findUnique({ where: { id: req.companyId } })
if (!company) {
return sendUnauthorized(res, 'company_not_found', 'Company not found')
}
req.company = company
next()
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
billingAccountUpdateSchema,
billingCreditNoteSchema,
billingRefundSchema,
createBillingInvoiceSchema,
payBillingInvoiceSchema,
} from './admin.schemas'
describe('admin billing schemas', () => {
it('accepts complete draft invoice payloads and preserves nullable billing fields', () => {
const parsed = createBillingInvoiceSchema.parse({
subscriptionId: null,
invoiceType: 'MANUAL',
currency: 'MAD',
dueAt: '2026-08-10',
isSubscriptionBlocking: false,
adminReason: null,
lineItems: [{
type: 'MANUAL_ADJUSTMENT',
description: 'Manual correction',
quantity: 2,
unitAmount: 1500,
periodStart: null,
periodEnd: '2026-08-31T00:00:00.000Z',
}],
})
expect(parsed.lineItems[0]).toMatchObject({ quantity: 2, unitAmount: 1500, periodStart: null })
})
it('rejects invoices without line items because empty invoices are bookkeeping cosplay', () => {
expect(() => createBillingInvoiceSchema.parse({ invoiceType: 'MANUAL', lineItems: [] })).toThrow()
})
it('bounds billing account net terms and validates email formatting', () => {
expect(billingAccountUpdateSchema.parse({
legalName: 'Atlas Cars LLC',
billingEmail: 'billing@example.test',
invoiceTerms: 'NET_30',
netTermsDays: 30,
})).toMatchObject({ netTermsDays: 30 })
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
})
it('requires positive money movements for payments, credit notes, and refunds', () => {
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
menuItemSchema,
menuPlanAssignmentsSchema,
menuCompanyAssignmentsSchema,
menuPreviewSchema,
promotionCreateSchema,
promotionUpdateSchema,
} from './admin.schemas'
describe('admin menu and promotion schemas', () => {
it('trims labels and applies safe menu item defaults', () => {
expect(menuItemSchema.parse({ label: ' Fleet ', itemType: 'INTERNAL_PAGE' })).toMatchObject({
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
displayOrder: 0,
openInNewTab: false,
isRequired: false,
isActive: true,
roles: [],
subscriptionPlans: [],
companyAssignments: [],
})
})
it('rejects negative menu ordering and invalid role previews', () => {
expect(() => menuItemSchema.parse({ label: 'Fleet', itemType: 'INTERNAL_PAGE', displayOrder: -1 })).toThrow()
expect(() => menuPreviewSchema.parse({ companyId: 'company_1', role: 'SUPER_ADMIN' })).toThrow()
})
it('requires at least one plan or company assignment', () => {
expect(() => menuPlanAssignmentsSchema.parse({ assignments: [] })).toThrow()
expect(() => menuCompanyAssignmentsSchema.parse({ assignments: [] })).toThrow()
expect(menuPlanAssignmentsSchema.parse({ assignments: [{ plan: 'PRO' }] })).toEqual({ assignments: [{ plan: 'PRO' }] })
})
it('accepts only uppercase promotion codes and preserves update partiality', () => {
const valid = {
code: 'SUMMER_26',
name: 'Summer 2026',
discountType: 'PERCENTAGE',
discountValue: 20,
plans: ['STARTER', 'GROWTH'],
periods: ['MONTHLY'],
validFrom: '2026-07-01T00:00:00.000Z',
}
expect(promotionCreateSchema.parse(valid)).toMatchObject({ ...valid, isActive: true })
expect(() => promotionCreateSchema.parse({ ...valid, code: 'summer' })).toThrow()
expect(promotionUpdateSchema.parse({ name: 'Updated name' })).toEqual({ name: 'Updated name' })
})
})
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { presentAdminSession, presentAdminUser, presentPaginated } from './admin.presenter'
describe('admin.presenter', () => {
it('removes admin secrets from user responses', () => {
const result = presentAdminUser({
id: 'admin_1',
email: 'admin@example.com',
role: 'SUPER_ADMIN',
passwordHash: 'hash',
totpSecret: 'secret',
})
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
})
it('wraps sessions without leaking credentials', () => {
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
token: 'jwt-token',
admin: { id: 'admin_1' },
})
})
it('computes pagination metadata and preserves extra aggregate fields', () => {
expect(presentPaginated([{ id: 'row_1' }], 41, 2, 20, { active: 11 })).toEqual({
data: [{ id: 'row_1' }],
total: 41,
page: 2,
pageSize: 20,
totalPages: 3,
active: 11,
})
})
})
+28
View File
@@ -0,0 +1,28 @@
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
const { passwordHash, totpSecret, ...safe } = admin
return safe
}
export function presentAdminSession<T extends Record<string, any>>(admin: T, token: string) {
return {
token,
admin: presentAdminUser(admin),
}
}
export function presentPaginated<T>(
data: T[],
total: number,
page: number,
pageSize: number,
extra: Record<string, unknown> = {},
) {
return {
data,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
...extra,
}
}
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
adminUser: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn() },
company: { findMany: vi.fn(), count: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn(), delete: vi.fn() },
billingAccount: { findMany: vi.fn(), count: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './admin.repo'
describe('admin.repo edge queries', () => {
beforeEach(() => vi.clearAllMocks())
it('clears reset token metadata when updating an admin password', async () => {
await repo.updateAdminPassword('admin_1', 'hash_1')
expect(prisma.adminUser.update).toHaveBeenCalledWith({
where: { id: 'admin_1' },
data: {
passwordHash: 'hash_1',
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
})
it('filters company list by search, status and plan with pagination', async () => {
vi.mocked(prisma.company.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.company.count).mockResolvedValue(0 as never)
await repo.listCompaniesPage({ q: 'atlas', status: 'ACTIVE', plan: 'PRO', page: 3, pageSize: 25 })
const where = {
status: 'ACTIVE',
OR: [
{ name: { contains: 'atlas', mode: 'insensitive' } },
{ email: { contains: 'atlas', mode: 'insensitive' } },
{ slug: { contains: 'atlas', mode: 'insensitive' } },
],
subscription: { plan: 'PRO' },
}
expect(prisma.company.findMany).toHaveBeenCalledWith(expect.objectContaining({
where,
skip: 50,
take: 25,
orderBy: { createdAt: 'desc' },
}))
expect(prisma.company.count).toHaveBeenCalledWith({ where })
})
it('looks up reset tokens only when they have not expired', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
await repo.findAdminByResetToken('reset-token')
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: 'reset-token',
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
},
})
vi.useRealTimers()
})
})
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
adminUser: {
findFirst: vi.fn(),
},
},
}))
import { prisma } from '../../lib/prisma'
import { findAdminByEmail } from './admin.repo'
describe('admin.repo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('looks up admin email case-insensitively', async () => {
await findAdminByEmail('Admin@Example.com')
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
email: {
equals: 'Admin@Example.com',
mode: 'insensitive',
},
},
})
})
})
+581
View File
@@ -0,0 +1,581 @@
import { prisma } from '../../lib/prisma'
const companyListInclude = {
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
contractSettings: { select: { legalName: true } },
subscription: { select: { plan: true, status: true } },
_count: { select: { employees: true, vehicles: true } },
} as const
const companyDetailInclude = {
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 } },
} as const
const billingInclude = {
company: { select: { id: true, name: true, email: true, slug: true, status: true } },
invoices: {
select: { id: true, amount: true, currency: true, status: true, paidAt: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 5,
},
_count: { select: { invoices: true } },
} as const
function parseOptionalDate(value?: string | null) {
if (!value) return null
return new Date(value)
}
export function findAdminByEmail(email: string) {
return prisma.adminUser.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
})
}
export function findAdminByIdOrThrow(id: string) {
return prisma.adminUser.findUniqueOrThrow({ where: { id } })
}
export function updateAdminLastLogin(id: string) {
return prisma.adminUser.update({ where: { id }, data: { lastLoginAt: new Date() } })
}
export function updateAdminTotpSecret(id: string, secret: string) {
return prisma.adminUser.update({ where: { id }, data: { totpSecret: secret } })
}
export function enableAdminTotp(id: string) {
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
}
export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
return prisma.$transaction(async (tx) => {
await tx.adminRecoveryCode.deleteMany({ where: { adminUserId } })
await tx.adminRecoveryCode.createMany({
data: codeHashes.map((codeHash) => ({ adminUserId, codeHash })),
})
})
}
export function listUnusedAdminRecoveryCodes(adminUserId: string) {
return prisma.adminRecoveryCode.findMany({
where: { adminUserId, usedAt: null },
select: { id: true, codeHash: true },
orderBy: { createdAt: 'asc' },
})
}
export function markAdminRecoveryCodeUsed(id: string) {
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
}
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
return prisma.adminUser.update({
where: { id },
data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
})
}
export function findAdminByResetToken(token: string) {
return prisma.adminUser.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
}
export function updateAdminPassword(id: string, passwordHash: string) {
return prisma.adminUser.update({
where: { id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
}
export function createAuditLog(data: Record<string, unknown>) {
return prisma.auditLog.create({ data: data as any })
}
export async function listCompaniesPage(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
const where: any = {}
if (query.status) where.status = query.status
if (query.q) {
where.OR = [
{ name: { contains: query.q, mode: 'insensitive' } },
{ email: { contains: query.q, mode: 'insensitive' } },
{ slug: { contains: query.q, mode: 'insensitive' } },
]
}
if (query.plan) where.subscription = { plan: query.plan }
const [data, total] = await Promise.all([
prisma.company.findMany({
where,
include: companyListInclude,
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.company.count({ where }),
])
return { data, total }
}
export function getCompanyDetail(id: string) {
return prisma.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
}
export function getCompanyUpdateSnapshot(id: string) {
return prisma.company.findUniqueOrThrow({
where: { id },
include: { brand: true, contractSettings: true, accountingSettings: true, subscription: true },
})
}
export async function applyCompanyUpdate(
id: string,
body: any,
current: {
name: string
slug: string
address?: unknown
brand?: { paymentMethodsEnabled?: any[] | null } | null
},
) {
return prisma.$transaction(async (tx: any) => {
if (body.company) {
const companyData = { ...body.company }
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
? current.address as Record<string, unknown>
: {}
companyData.address = { ...baseAddress, ...companyData.address }
}
await tx.company.update({ where: { id }, data: companyData })
}
if (body.subscription) {
const sub = body.subscription
await tx.subscription.upsert({
where: { companyId: id },
update: {
...sub,
trialStartAt: parseOptionalDate(sub.trialStartAt),
trialEndAt: parseOptionalDate(sub.trialEndAt),
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
cancelledAt: parseOptionalDate(sub.cancelledAt),
},
create: {
companyId: id,
plan: sub.plan ?? 'STARTER',
billingPeriod: sub.billingPeriod ?? 'MONTHLY',
status: sub.status ?? 'TRIALING',
currency: sub.currency ?? 'MAD',
trialStartAt: parseOptionalDate(sub.trialStartAt),
trialEndAt: parseOptionalDate(sub.trialEndAt),
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
cancelledAt: parseOptionalDate(sub.cancelledAt),
cancelAtPeriodEnd: sub.cancelAtPeriodEnd ?? false,
} as any,
})
}
if (body.brand) {
await tx.brandSettings.upsert({
where: { companyId: id },
update: body.brand,
create: {
companyId: id,
displayName: body.brand.displayName ?? current.name,
subdomain: body.brand.subdomain ?? current.slug,
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
...body.brand,
} as any,
})
}
if (body.contractSettings) {
await tx.contractSettings.upsert({
where: { companyId: id },
update: body.contractSettings,
create: { companyId: id, ...body.contractSettings } as any,
})
}
if (body.accountingSettings) {
await tx.accountingSettings.upsert({
where: { companyId: id },
update: body.accountingSettings,
create: { companyId: id, ...body.accountingSettings } as any,
})
}
return tx.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
})
}
export function updateCompanyStatus(id: string, status: string) {
return prisma.company.update({ where: { id }, data: { status: status as any } })
}
export function deleteCompany(id: string) {
return prisma.company.delete({ where: { id } })
}
export function getCompanyForImpersonation(id: string) {
return prisma.company.findUniqueOrThrow({
where: { id },
include: { employees: { where: { role: 'OWNER' } } },
})
}
export async function listRentersPage(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
const where: any = {}
if (query.blocked !== undefined) where.isActive = query.blocked === 'false'
if (query.q) {
where.OR = [
{ firstName: { contains: query.q, mode: 'insensitive' } },
{ email: { contains: query.q, mode: 'insensitive' } },
]
}
const [data, total] = await Promise.all([
prisma.renter.findMany({
where,
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
isActive: true,
createdAt: true,
_count: { select: { reservations: true } },
},
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.renter.count({ where }),
])
return { data, total }
}
export function updateRenterActive(id: string, isActive: boolean) {
return prisma.renter.update({ where: { id }, data: { isActive } })
}
export async function getPlatformMetricCounts() {
const [
totalCompanies,
activeCompanies,
trialingCompanies,
suspendedCompanies,
totalRenters,
totalReservations,
] = await Promise.all([
prisma.company.count(),
prisma.company.count({ where: { status: 'ACTIVE' } }),
prisma.company.count({ where: { status: 'TRIALING' } }),
prisma.company.count({ where: { status: 'SUSPENDED' } }),
prisma.renter.count(),
prisma.reservation.count(),
])
return {
totalCompanies,
activeCompanies,
trialingCompanies,
suspendedCompanies,
totalRenters,
totalReservations,
}
}
export async function listAuditLogsPage(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
const where: any = {}
if (query.adminId) where.adminUserId = query.adminId
if (query.action) where.action = { contains: query.action }
if (query.companyId) where.companyId = query.companyId
if (query.entityId) where.resourceId = query.entityId
const [data, total] = await Promise.all([
prisma.auditLog.findMany({
where,
include: { adminUser: { select: { firstName: true, lastName: true, email: true } } },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.auditLog.count({ where }),
])
return { data, total }
}
export function listAdmins() {
return prisma.adminUser.findMany({
include: { permissions: true },
})
}
export function createAdmin(data: {
email: string
firstName: string
lastName: string
role: string
passwordHash: string
permissions?: any[]
}) {
return prisma.adminUser.create({
data: {
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
role: data.role as any,
passwordHash: data.passwordHash,
permissions: data.permissions ? { create: data.permissions } : undefined,
},
include: { permissions: true },
})
}
export function updateAdminRole(id: string, role: string) {
return prisma.adminUser.update({ where: { id }, data: { role: role as any } })
}
export function updateAdmin(
id: string,
data: {
email?: string
firstName?: string
lastName?: string
role?: string
passwordHash?: string
isActive?: boolean
},
) {
return prisma.adminUser.update({
where: { id },
data: {
...(data.email !== undefined ? { email: data.email } : {}),
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.role !== undefined ? { role: data.role as any } : {}),
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
include: { permissions: true },
})
}
export async function replaceAdminPermissions(id: string, permissions: any[]) {
await prisma.adminUser.findUniqueOrThrow({ where: { id } })
await prisma.$transaction([
prisma.adminPermission.deleteMany({ where: { adminUserId: id } }),
prisma.adminPermission.createMany({
data: permissions.map((permission) => ({
adminUserId: id,
resource: permission.resource,
actions: permission.actions,
})) as any,
}),
])
return prisma.adminUser.findUniqueOrThrow({ where: { id }, include: { permissions: true } })
}
export async function listBillingPage(query: { status?: string; plan?: string; page: number; pageSize: number }) {
const where: any = {}
if (query.status) where.status = query.status
if (query.plan) where.plan = query.plan
const [data, total] = await Promise.all([
prisma.subscription.findMany({
where,
include: billingInclude,
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.subscription.count({ where }),
])
return { data, total }
}
export function listActiveSubscriptionsForMrr() {
return prisma.subscription.findMany({
where: { status: { in: ['ACTIVE', 'TRIALING'] as any[] } },
select: { plan: true, billingPeriod: true },
})
}
export async function getBillingStatusCounts() {
const [activeCount, trialingCount, pastDueCount, cancelledCount] = await Promise.all([
prisma.subscription.count({ where: { status: 'ACTIVE' } }),
prisma.subscription.count({ where: { status: 'TRIALING' } }),
prisma.subscription.count({ where: { status: 'PAST_DUE' } }),
prisma.subscription.count({ where: { status: 'CANCELLED' } }),
])
return { activeCount, trialingCount, pastDueCount, cancelledCount }
}
export async function listCompanyInvoicesPage(companyId: string, query: { page: number; pageSize: number }) {
const [data, total] = await Promise.all([
prisma.subscriptionInvoice.findMany({
where: { companyId },
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.subscriptionInvoice.count({ where: { companyId } }),
])
return { data, total }
}
export function getInvoicePdfRecord(invoiceId: string) {
return prisma.subscriptionInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: {
company: { select: { name: true, email: true, phone: true, address: true } },
subscription: {
select: {
plan: true,
billingPeriod: true,
currency: true,
currentPeriodStart: true,
currentPeriodEnd: true,
},
},
},
})
}
export function listPricingConfigs() {
return prisma.pricingConfig.findMany({ orderBy: [{ plan: 'asc' }, { billingPeriod: 'asc' }] })
}
export function upsertPricingConfig(plan: string, billingPeriod: string, amount: number, updatedBy?: string) {
return prisma.pricingConfig.upsert({
where: { plan_billingPeriod: { plan, billingPeriod } },
update: { amount, updatedBy },
create: { id: `prc_${plan.toLowerCase()}_${billingPeriod.toLowerCase()}`, plan, billingPeriod, amount, updatedBy },
})
}
export function listPlanFeatures() {
return prisma.planFeature.findMany({
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
})
}
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
return prisma.planFeature.create({
data: {
plan: data.plan,
label: data.label,
sortOrder: data.sortOrder ?? 0,
},
})
}
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
return prisma.planFeature.update({
where: { id },
data,
})
}
export function deletePlanFeature(id: string) {
return prisma.planFeature.delete({ where: { id } })
}
// ─── Pricing promotions ────────────────────────────────────────────────────
export function listPromotions() {
return (prisma as any).pricingPromotion.findMany({ orderBy: { createdAt: 'desc' } })
}
export function createPromotion(data: {
code: string; name: string; description?: string | null
discountType: string; discountValue: number
plans: string[]; periods: string[]
maxUses?: number | null; validFrom: string; validUntil?: string | null
isActive: boolean; createdBy?: string | null
}) {
return (prisma as any).pricingPromotion.create({
data: {
...data,
validFrom: new Date(data.validFrom),
validUntil: data.validUntil ? new Date(data.validUntil) : null,
},
})
}
export function updatePromotion(id: string, data: Partial<{
code: string; name: string; description: string | null
discountType: string; discountValue: number
plans: string[]; periods: string[]
maxUses: number | null; validFrom: string; validUntil: string | null
isActive: boolean
}>) {
const { validFrom, validUntil, ...rest } = data
return (prisma as any).pricingPromotion.update({
where: { id },
data: {
...rest,
...(validFrom ? { validFrom: new Date(validFrom) } : {}),
...(validUntil !== undefined ? { validUntil: validUntil ? new Date(validUntil) : null } : {}),
},
})
}
export function deletePromotion(id: string) {
return (prisma as any).pricingPromotion.delete({ where: { id } })
}
export async function listNotificationsPage(query: {
channel?: string
status?: string
companyId?: string
page: number
pageSize: number
}) {
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const [data, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
}),
prisma.notification.count({ where }),
])
return { data, total }
}
+575
View File
@@ -0,0 +1,575 @@
import { Router } from 'express'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
updateAdminSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
} from './admin.schemas'
import { z } from 'zod'
const adminSubOverrideSchema = z.object({
reason: z.string().min(1).max(500),
})
const adminExtendTrialSchema = z.object({
extraDays: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
const adminImpersonationSchema = z.object({
reason: z.string().min(5).max(500),
durationMinutes: z.number().int().min(1).max(30).default(15),
})
const subIdParamSchema = z.object({ subscriptionId: z.string() })
const router = Router()
// ─── Auth (public) ─────────────────────────────────────────────
router.post('/auth/login', async (req, res, next) => {
try {
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode, recoveryCode)
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/auth/logout', (_req, res) => {
clearSessionCookie(res, 'admin')
ok(res, { success: true })
})
router.post('/auth/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(forgotPasswordSchema, req)
await service.forgotPassword(email)
ok(res, { message: 'If that email is registered, a reset link has been sent.' })
} catch (err) { next(err) }
})
router.post('/auth/reset-password', async (req, res, next) => {
try {
const { token, password } = parseBody(resetPasswordSchema, req)
const ok2 = await service.resetPassword(token, password)
if (!ok2) return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
ok(res, { message: 'Password updated successfully. You can now sign in.' })
} catch (err) { next(err) }
})
// ─── Auth (protected) ──────────────────────────────────────────
router.get('/auth/me', requireAdminAuth, (req, res) => {
ok(res, presentAdminUser(req.admin as any))
})
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.setupTotp(req.admin.id, req.admin.email))
} catch (err) { next(err) }
})
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
try {
const { code } = parseBody(totpVerifySchema, req)
const result = await service.verifyTotp(req.admin.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
} catch (err) { next(err) }
})
router.post('/auth/2fa/recovery-codes/regenerate', requireAdminAuth, requireFreshAdmin2FA, async (req, res, next) => {
try {
ok(res, await service.regenerateRecoveryCodes(req.admin.id))
} catch (err) { next(err) }
})
// ─── Companies ─────────────────────────────────────────────────
router.get('/companies', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.listCompanies(parseQuery(companiesQuerySchema, req)))
} catch (err) { next(err) }
})
router.get('/companies/:id', requireAdminAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.getCompany(id))
} catch (err) { next(err) }
})
router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(adminCompanyUpdateSchema, req)
ok(res, await service.updateCompany(id, body, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { status, reason } = parseBody(companyStatusSchema, req)
ok(res, await service.setCompanyStatus(id, status, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteCompany(id, req.admin.id, req.ip)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { reason, durationMinutes } = parseBody(adminImpersonationSchema, req)
ok(res, await service.impersonateCompany(id, req.admin.id, req.ip, reason, durationMinutes))
} catch (err) { next(err) }
})
// ─── Menu management ──────────────────────────────────────────
router.get('/menu-items', requireAdminAuth, requireAdminRole('SUPPORT'), async (_req, res, next) => {
try {
ok(res, await menuService.listMenuItems())
} catch (err) { next(err) }
})
router.post('/menu-items', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
created(res, { data: await menuService.createMenuItem(parseBody(menuItemSchema, req), req.admin) })
} catch (err) { next(err) }
})
router.get('/menu-items/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await menuService.getMenuItem(id))
} catch (err) { next(err) }
})
router.put('/menu-items/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await menuService.updateMenuItem(id, parseBody(menuItemSchema, req), req.admin))
} catch (err) { next(err) }
})
router.patch('/menu-items/:id/status', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { isActive } = parseBody(menuItemStatusSchema, req)
ok(res, await menuService.setMenuItemStatus(id, isActive, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await menuService.deleteMenuItem(id, req.admin)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/menu-items/:id/subscriptions', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { assignments } = parseBody(menuPlanAssignmentsSchema, req)
ok(res, await menuService.assignMenuItemToPlans(id, assignments, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id/subscriptions/:plan', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id, plan } = parseParams(menuPlanParamSchema, req)
ok(res, await menuService.removeMenuItemFromPlan(id, plan, req.admin))
} catch (err) { next(err) }
})
router.post('/menu-items/:id/companies', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { assignments } = parseBody(menuCompanyAssignmentsSchema, req)
ok(res, await menuService.assignMenuItemToCompanies(id, assignments, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id/companies/:companyId', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id, companyId } = parseParams(menuCompanyParamSchema, req)
ok(res, await menuService.removeMenuItemFromCompany(id, companyId, req.admin))
} catch (err) { next(err) }
})
router.post('/menu-preview', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await menuService.previewCompanyMenu(parseBody(menuPreviewSchema, req)))
} catch (err) { next(err) }
})
router.get('/menu-audit-logs', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await menuService.listMenuAuditLogs(parseQuery(menuAuditLogQuerySchema, req)))
} catch (err) { next(err) }
})
// ─── Renters ───────────────────────────────────────────────────
router.get('/renters', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.listRenters(parseQuery(rentersQuerySchema, req)))
} catch (err) { next(err) }
})
router.post('/renters/:id/block', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.setRenterActive(id, false)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/renters/:id/unblock', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.setRenterActive(id, true)
ok(res, { success: true })
} catch (err) { next(err) }
})
// ─── Metrics ───────────────────────────────────────────────────
router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.getPlatformMetrics())
} catch (err) { next(err) }
})
// ─── Notifications ─────────────────────────────────────────────
router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await service.listNotifications(parseQuery(notificationsQuerySchema, req)))
} catch (err) { next(err) }
})
// ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
ok(res, await service.getAuditLogs(parseQuery(auditLogQuerySchema, req)))
} catch (err) { next(err) }
})
// ─── Admin users ───────────────────────────────────────────────
router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
ok(res, await service.listAdmins())
} catch (err) { next(err) }
})
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
} catch (err) { next(err) }
})
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.updateAdmin(id, parseBody(updateAdminSchema, req)))
} catch (err) { next(err) }
})
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { role } = parseBody(adminRoleSchema, req)
await service.updateAdminRole(id, role)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { permissions } = parseBody(adminPermissionsSchema, req)
ok(res, await service.updateAdminPermissions(id, permissions))
} catch (err) { next(err) }
})
// ─── Billing ───────────────────────────────────────────────────
router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.getBilling(parseQuery(billingQuerySchema, req)))
} catch (err) { next(err) }
})
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { pdfBuffer, invoiceNumber } = await service.getInvoicePdf(invoiceId)
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.end(pdfBuffer)
} catch (err) { next(err) }
})
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
ok(res, await service.getBillingAccountDetail(companyId))
} catch (err) { next(err) }
})
router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
ok(res, await service.getCompanyInvoices(companyId, parseQuery(invoicesQuerySchema, req)))
} catch (err) { next(err) }
})
router.patch('/billing/accounts/:billingAccountId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.updateBillingAccount(billingAccountId, parseBody(billingAccountUpdateSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/pause-dunning', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.setDunningPaused(billingAccountId, true, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/resume-dunning', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.setDunningPaused(billingAccountId, false, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
created(res, await service.createBillingInvoice(billingAccountId, parseBody(createBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/finalize', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.finalizeBillingInvoice(invoiceId, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.payBillingInvoice(invoiceId, parseBody(payBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/retry-payment', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.retryBillingInvoicePayment(invoiceId, parseBody(retryBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/void', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { reason } = parseBody(billingReasonSchema, req)
ok(res, await service.voidBillingInvoice(invoiceId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/uncollectible', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { reason } = parseBody(billingReasonSchema, req)
ok(res, await service.markBillingInvoiceUncollectible(invoiceId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/credit-notes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.issueBillingCreditNote(invoiceId, parseBody(billingCreditNoteSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.issueBillingRefund(invoiceId, parseBody(billingRefundSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
// ─── Pricing config ────────────────────────────────────────────
router.get('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.getPricingConfigs())
} catch (err) { next(err) }
})
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { entries } = parseBody(pricingUpdateSchema, req)
ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
try {
ok(res, await service.listPlanFeatures())
} catch (err) { next(err) }
})
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const data = parseBody(planFeatureCreateSchema, req)
created(res, await service.createPlanFeature(data, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
const data = parseBody(planFeatureUpdateSchema, req)
ok(res, await service.updatePlanFeature(featureId, data, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
await service.deletePlanFeature(featureId, req.admin.id, req.ip)
ok(res, { success: true })
} catch (err) { next(err) }
})
// ─── Promotions ────────────────────────────────────────────────────────────
router.get('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.listPromotions())
} catch (err) { next(err) }
})
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const data = parseBody(promotionCreateSchema, req)
created(res, await service.createPromotion(data as any, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
const data = parseBody(promotionUpdateSchema, req)
ok(res, await service.updatePromotion(promotionId, data as any, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
await service.deletePromotion(promotionId, req.admin.id, req.ip)
ok(res, { success: true })
} catch (err) { next(err) }
})
// ─── Subscription admin overrides ─────────────────────────────
router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
ok(res, await subService.getEventsBySubscriptionId(subscriptionId))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { extraDays, reason } = parseBody(adminExtendTrialSchema, req)
ok(res, await subService.adminExtendTrial(subscriptionId, extraDays, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminExtendGracePeriod(subscriptionId, 7, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminSuspendSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminReactivateSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
ok(res, await subService.adminCancelSubscription(subscriptionId, req.admin.id, reason))
} catch (err) { next(err) }
})
// ─── Site config ───────────────────────────────────────────────
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.getMarketplaceHomepage())
} catch (err) { next(err) }
})
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { homepage } = parseBody(homepageUpdateSchema, req)
ok(res, await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip))
} catch (err) { next(err) }
})
export default router
+411
View File
@@ -0,0 +1,411 @@
import { z } from 'zod'
import type { MarketplaceHomepageContent, MarketplaceHomepageHowItWorksStep, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep, MarketplaceHomepageTestimonial } from '@rentaldrivego/types'
export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
recoveryCode: z.string().min(8).max(32).optional(),
})
export const forgotPasswordSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
})
export const resetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
})
export const totpVerifySchema = z.object({
code: z.string().length(6),
})
export const companiesQuerySchema = z.object({
q: z.string().optional(),
status: z.string().optional(),
plan: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const rentersQuerySchema = z.object({
q: z.string().optional(),
blocked: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const auditLogQuerySchema = z.object({
adminId: z.string().optional(),
action: z.string().optional(),
companyId: z.string().optional(),
entityId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const notificationsQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
})
export const billingQuerySchema = z.object({
q: z.string().optional(),
status: z.string().optional(),
plan: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const invoicesQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const permissionSchema = z.object({
resource: z.enum(['COMPANIES', 'COMPANY_EMPLOYEES', 'SUBSCRIPTIONS', 'PAYMENTS', 'OFFERS', 'RENTERS', 'ADMIN_USERS', 'AUDIT_LOGS', 'PLATFORM_METRICS']),
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
})
export const createAdminSchema = z.object({
email: z.string().email().trim().toLowerCase(),
firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(100).trim(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
password: z.string().min(8),
permissions: z.array(permissionSchema).optional(),
})
export const updateAdminSchema = z.object({
email: z.string().email().trim().toLowerCase().optional(),
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
password: z.string().min(8).optional(),
isActive: z.boolean().optional(),
})
export const adminRoleSchema = z.object({
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
})
export const adminPermissionsSchema = z.object({
permissions: z.array(permissionSchema),
})
export const companyStatusSchema = z.object({
status: z.enum(['ACTIVE', 'SUSPENDED', 'CANCELLED']),
reason: z.string().optional(),
})
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()
export 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,
address: z.object({
streetAddress: nullableString,
city: nullableString,
country: nullableString,
zipCode: nullableString,
legalName: nullableString,
legalForm: nullableString,
companyEmail: nullableEmail,
managerName: nullableString,
iceNumber: nullableString,
operatingLicenseNumber: nullableString,
operatingLicenseIssuedAt: nullableString,
operatingLicenseIssuedBy: nullableString,
fax: nullableString,
yearsActive: nullableString,
representativeName: nullableString,
representativeTitle: nullableString,
responsibleName: nullableString,
responsibleRole: nullableString,
responsibleIdentityNumber: nullableString,
responsibleQualification: nullableString,
responsiblePhone: nullableString,
responsibleEmail: nullableEmail,
}).optional(),
}).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.literal('MAD').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.literal('MAD').optional(),
isListedOnMarketplace: z.boolean().optional(),
homePageConfig: z.any().optional(),
menuConfig: z.any().optional(),
}).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.literal('MAD').optional(),
accountantEmail: nullableEmail, accountantName: nullableString,
autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).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 marketplaceHowItWorksStepSchema: z.ZodType<MarketplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
const marketplaceTestimonialSchema: z.ZodType<MarketplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const 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),
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(marketplaceHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(marketplaceTestimonialSchema).min(1),
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: z.string().min(1),
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
})
export const marketplaceHomepageConfigSchema = z.object({
en: marketplaceHomepageContentSchema,
fr: marketplaceHomepageContentSchema,
ar: marketplaceHomepageContentSchema,
})
export const idParamSchema = z.object({
id: z.string().min(1),
})
export const companyIdParamSchema = z.object({
companyId: z.string().min(1),
})
export const billingAccountIdParamSchema = z.object({
billingAccountId: z.string().min(1),
})
export const invoiceIdParamSchema = z.object({
invoiceId: z.string().min(1),
})
export const billingAccountUpdateSchema = z.object({
legalName: z.string().min(1).max(255).optional(),
billingEmail: z.string().email().optional(),
billingAddress: z.any().optional(),
taxId: z.union([z.string().max(120), z.null()]).optional(),
taxExempt: z.boolean().optional(),
invoiceTerms: z.enum(['DUE_ON_RECEIPT', 'NET_7', 'NET_15', 'NET_30', 'NET_45', 'NET_60']).optional(),
netTermsDays: z.number().int().min(0).max(365).optional(),
})
export const billingLineItemInputSchema = z.object({
type: z.enum([
'SUBSCRIPTION_FEE',
'SEAT_FEE',
'USAGE_FEE',
'SETUP_FEE',
'DISCOUNT',
'TAX',
'CREDIT',
'PRORATION',
'REFUND_ADJUSTMENT',
'MANUAL_ADJUSTMENT',
'PROFESSIONAL_SERVICES',
'CANCELLATION_FEE',
]),
description: z.string().min(1).max(255),
quantity: z.number().int().positive().max(100000),
unitAmount: z.number().int(),
periodStart: nullableDate,
periodEnd: nullableDate,
})
export const createBillingInvoiceSchema = z.object({
subscriptionId: z.union([z.string(), z.null()]).optional(),
invoiceType: z.enum([
'SUBSCRIPTION_INITIAL',
'SUBSCRIPTION_RENEWAL',
'TRIAL_CONVERSION',
'SUBSCRIPTION_UPGRADE',
'SUBSCRIPTION_DOWNGRADE_CREDIT',
'USAGE_BASED',
'ONE_TIME',
'MANUAL',
'CORRECTION',
'CANCELLATION_FEE',
]),
currency: z.string().min(3).max(8).optional(),
dueAt: nullableDate,
isSubscriptionBlocking: z.boolean().optional(),
adminReason: z.union([z.string().max(500), z.null()]).optional(),
lineItems: z.array(billingLineItemInputSchema).min(1),
})
export const payBillingInvoiceSchema = z.object({
amount: z.number().int().positive().optional(),
paymentMethodId: z.union([z.string(), z.null()]).optional(),
providerPaymentId: z.union([z.string(), z.null()]).optional(),
})
export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(),
})
export const billingReasonSchema = z.object({
reason: z.string().min(1).max(500),
})
export const billingCreditNoteSchema = z.object({
amount: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
export const billingRefundSchema = z.object({
amount: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema,
})
export const pricingUpdateSchema = z.object({
entries: z.array(z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
amount: z.number().int().positive(),
})).min(1),
})
const planFeatureSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
label: z.string().min(1).max(120),
sortOrder: z.number().int().min(0).default(0),
})
export const planFeatureCreateSchema = planFeatureSchema
export const planFeatureUpdateSchema = planFeatureSchema.partial()
export const promotionCreateSchema = z.object({
code: z.string().min(2).max(30).regex(/^[A-Z0-9_-]+$/, 'Code must be uppercase A-Z, 0-9, _ or -'),
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
discountType: z.enum(['PERCENTAGE', 'FIXED']),
discountValue: z.number().int().positive(),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])),
periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
maxUses: z.number().int().positive().nullable().optional(),
validFrom: z.string().datetime(),
validUntil: z.string().datetime().nullable().optional(),
isActive: z.boolean().default(true),
})
export const promotionUpdateSchema = promotionCreateSchema.partial()
export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1) })
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO'])
const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
export const menuItemSchema = z.object({
systemKey: z.string().trim().min(1).max(120).optional().nullable(),
label: z.string().trim().min(1).max(120),
itemType: menuItemTypeSchema,
routeOrUrl: z.string().trim().max(400).optional().nullable(),
icon: z.string().trim().max(120).optional().nullable(),
parentId: z.string().trim().min(1).optional().nullable(),
displayOrder: z.number().int().min(0).default(0),
openInNewTab: z.boolean().default(false),
isRequired: z.boolean().default(false),
isActive: z.boolean().default(true),
roles: z.array(employeeRoleSchema).default([]),
subscriptionPlans: z.array(z.object({
plan: planSchema,
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).default([]),
companyAssignments: z.array(z.object({
companyId: z.string().trim().min(1),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).default([]),
})
export const menuItemStatusSchema = z.object({
isActive: z.boolean(),
})
export const menuPlanAssignmentsSchema = z.object({
assignments: z.array(z.object({
plan: planSchema,
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).min(1),
})
export const menuCompanyAssignmentsSchema = z.object({
assignments: z.array(z.object({
companyId: z.string().trim().min(1),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).min(1),
})
export const menuPreviewSchema = z.object({
companyId: z.string().trim().min(1),
role: employeeRoleSchema,
})
export const menuAuditLogQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const menuItemIdParamSchema = z.object({
id: z.string().min(1),
})
export const menuPlanParamSchema = z.object({
id: z.string().min(1),
plan: planSchema,
})
export const menuCompanyParamSchema = z.object({
id: z.string().min(1),
companyId: z.string().min(1),
})
@@ -0,0 +1,49 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(),
setAdminPasswordReset: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword } from './admin.service'
describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL
beforeEach(() => {
vi.clearAllMocks()
process.env.ADMIN_URL = 'http://localhost:3000/admin'
})
afterAll(() => {
process.env.ADMIN_URL = originalAdminUrl
})
it('sends the reset email to the canonical stored admin address', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_1',
email: 'admin@rentaldrivego.com',
firstName: 'Samir',
isActive: true,
} as any)
await forgotPassword('Admin@RentalDriveGo.com')
expect(repo.setAdminPasswordReset).toHaveBeenCalledWith(
'admin_1',
expect.any(String),
expect.any(Date),
)
expect(sendTransactionalEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'admin@rentaldrivego.com',
}),
)
})
})
+514
View File
@@ -0,0 +1,514 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
const ADMIN_RECOVERY_CODE_COUNT = 10
function generateRecoveryCode() {
const raw = crypto.randomBytes(9).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 12)
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`
}
async function issueAdminRecoveryCodes(adminId: string) {
const codes = Array.from({ length: ADMIN_RECOVERY_CODE_COUNT }, generateRecoveryCode)
const hashes = await Promise.all(codes.map((code) => bcrypt.hash(code, 12)))
await repo.replaceAdminRecoveryCodes(adminId, hashes)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_RECOVERY_CODES_ISSUED',
resource: 'AdminUser',
resourceId: adminId,
})
return codes
}
async function consumeAdminRecoveryCode(adminId: string, code: string) {
const normalized = code.trim().toUpperCase()
if (!normalized) return false
const codes = await repo.listUnusedAdminRecoveryCodes(adminId)
for (const candidate of codes) {
if (await bcrypt.compare(normalized, candidate.codeHash)) {
await repo.markAdminRecoveryCodeUsed(candidate.id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_RECOVERY_CODE_USED',
resource: 'AdminUser',
resourceId: adminId,
})
return true
}
}
return false
}
function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
}
function toAuditJson<T>(value: T) {
return JSON.parse(JSON.stringify(value))
}
function ensureAdminBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/admin')) url.pathname = `${pathname}/admin`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/admin') ? trimmed : `${trimmed}/admin`
}
}
export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return null
const valid = await bcrypt.compare(password, admin.passwordHash)
if (!valid) return null
if (admin.totpEnabled) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validRecoveryCode = !validTotp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
}
await repo.updateAdminLastLogin(admin.id)
await repo.createAuditLog({
adminUserId: admin.id,
action: 'ADMIN_LOGIN',
resource: 'AdminUser',
resourceId: admin.id,
})
return presenter.presentAdminSession(admin, signAdminToken(admin.id, admin.totpEnabled ? Date.now() : undefined))
}
export async function setupTotp(adminId: string, email: string) {
const secret = authenticator.generateSecret()
await repo.updateAdminTotpSecret(adminId, secret)
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode }
}
export async function verifyTotp(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
if (!admin.totpSecret) return false
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
if (!valid) return false
await repo.enableAdminTotp(adminId)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_VERIFIED',
resource: 'AdminUser',
resourceId: adminId,
})
const recoveryCodes = await issueAdminRecoveryCodes(adminId)
return {
...presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())),
recoveryCodes,
}
}
export async function regenerateRecoveryCodes(adminId: string) {
return { recoveryCodes: await issueAdminRecoveryCodes(adminId) }
}
export async function forgotPassword(email: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000)
await repo.setAdminPasswordReset(admin.id, rawToken, expiresAt)
const adminUrl = ensureAdminBasePath(
process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin',
)
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
await sendTransactionalEmail({
to: admin.email,
subject: 'Reset your RentalDriveGo admin password',
html: `<p>Hi ${admin.firstName},</p><p><a href="${resetUrl}">Reset password</a></p><p>Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.</p>`,
text: `Hi ${admin.firstName},\n\nReset here: ${resetUrl}\n\nExpires in ${ADMIN_RESET_TTL_MINUTES} minutes.`,
}).catch((err) => console.error('[AdminForgotPassword]', err?.message))
}
export async function resetPassword(token: string, password: string) {
const admin = await repo.findAdminByResetToken(token)
if (!admin) return false
await repo.updateAdminPassword(admin.id, await bcrypt.hash(password, 12))
return true
}
export async function listCompanies(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listCompaniesPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export function getCompany(id: string) {
return repo.getCompanyDetail(id)
}
export async function updateCompany(id: string, body: any, adminId: string, ip?: string) {
const before = await repo.getCompanyUpdateSnapshot(id)
const updated = await repo.applyCompanyUpdate(id, body, before)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE_COMPANY',
resource: 'Company',
resourceId: id,
companyId: id,
before: toAuditJson(before),
after: toAuditJson(body),
ipAddress: ip,
})
return updated
}
export async function setCompanyStatus(id: string, status: string, reason: string | undefined, adminId: string, ip?: string) {
const before = await repo.getCompanyUpdateSnapshot(id)
const updated = await repo.updateCompanyStatus(id, status)
await repo.createAuditLog({
adminUserId: adminId,
action: `SET_COMPANY_STATUS_${status}`,
resource: 'Company',
resourceId: id,
companyId: id,
before: { status: before.status },
after: { status },
note: reason,
ipAddress: ip,
})
return updated
}
export async function deleteCompany(id: string, adminId: string, ip?: string) {
await repo.deleteCompany(id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'DELETE_COMPANY',
resource: 'Company',
resourceId: id,
ipAddress: ip,
})
}
export async function impersonateCompany(id: string, adminId: string, ip?: string, reason?: string, durationMinutes = 15) {
const company = await repo.getCompanyForImpersonation(id)
const ttlMinutes = Math.min(Math.max(durationMinutes, 1), 30)
const employeeId = company.employees[0]?.id
if (!employeeId) throw new Error('Company has no employee account to impersonate')
const token = signActorToken(employeeId, 'employee', { expiresIn: `${ttlMinutes}m` as any })
await repo.createAuditLog({
adminUserId: adminId,
action: 'IMPERSONATE_COMPANY',
resource: 'Company',
resourceId: id,
companyId: id,
note: reason,
before: { originalAdminId: adminId },
after: { targetCompanyId: id, durationMinutes: ttlMinutes },
ipAddress: ip,
})
return { token, expiresIn: ttlMinutes * 60, impersonation: { companyId: id, reason, durationMinutes: ttlMinutes } }
}
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listRentersPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export function setRenterActive(id: string, isActive: boolean) {
return repo.updateRenterActive(id, isActive)
}
export function getPlatformMetrics() {
return repo.getPlatformMetricCounts()
}
export async function listNotifications(query: { channel?: string; status?: string; companyId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listNotificationsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listAuditLogsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function listAdmins() {
const admins = await repo.listAdmins()
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
const admin = await repo.createAdmin({
...body,
passwordHash: await bcrypt.hash(body.password, 12),
})
return presenter.presentAdminUser(admin)
}
export async function updateAdmin(
id: string,
body: {
email?: string
firstName?: string
lastName?: string
role?: string
password?: string
isActive?: boolean
},
) {
const admin = await repo.updateAdmin(id, {
...(body.email !== undefined ? { email: body.email } : {}),
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
...(body.role !== undefined ? { role: body.role } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
})
return presenter.presentAdminUser(admin)
}
export function updateAdminRole(id: string, role: string) {
return repo.updateAdminRole(id, role)
}
export async function updateAdminPermissions(id: string, permissions: any[]) {
const admin = await repo.replaceAdminPermissions(id, permissions)
return presenter.presentAdminUser(admin)
}
export async function getBilling(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
const { data, total, stats } = await billingService.listBillingAccounts(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize, { stats })
}
export async function getCompanyInvoices(companyId: string, query: { page: number; pageSize: number }) {
const detail = await billingService.getBillingAccountDetail(companyId)
const start = (query.page - 1) * query.pageSize
const end = start + query.pageSize
const data = detail.invoices.slice(start, end)
const total = detail.invoices.length
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function getInvoicePdf(invoiceId: string) {
return billingService.getInvoicePdf(invoiceId)
}
export function getBillingAccountDetail(companyId: string) {
return billingService.getBillingAccountDetail(companyId)
}
export function updateBillingAccount(
billingAccountId: string,
data: Parameters<typeof billingService.updateBillingAccount>[1],
adminId: string,
ip?: string,
) {
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
}
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
}
export function createBillingInvoice(
billingAccountId: string,
data: Parameters<typeof billingService.createDraftInvoice>[1],
adminId: string,
ip?: string,
) {
return billingService.createDraftInvoice(billingAccountId, data, adminId, ip)
}
export function finalizeBillingInvoice(invoiceId: string, adminId: string, ip?: string) {
return billingService.finalizeInvoice(invoiceId, adminId, ip)
}
export function payBillingInvoice(
invoiceId: string,
data: Parameters<typeof billingService.payInvoice>[1],
adminId: string,
ip?: string,
) {
return billingService.payInvoice(invoiceId, data, adminId, ip)
}
export function retryBillingInvoicePayment(
invoiceId: string,
data: Parameters<typeof billingService.retryInvoicePayment>[1],
adminId: string,
ip?: string,
) {
return billingService.retryInvoicePayment(invoiceId, data, adminId, ip)
}
export function voidBillingInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) {
return billingService.voidInvoice(invoiceId, reason, adminId, ip)
}
export function markBillingInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) {
return billingService.markInvoiceUncollectible(invoiceId, reason, adminId, ip)
}
export function issueBillingCreditNote(
invoiceId: string,
data: Parameters<typeof billingService.issueCreditNote>[1],
adminId: string,
ip?: string,
) {
return billingService.issueCreditNote(invoiceId, data, adminId, ip)
}
export function issueBillingRefund(
invoiceId: string,
data: Parameters<typeof billingService.issueRefund>[1],
adminId: string,
ip?: string,
) {
return billingService.issueRefund(invoiceId, data, adminId, ip)
}
export function getMarketplaceHomepage() {
return getMarketplaceHomepageContent()
}
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveMarketplaceHomepageContent(homepage)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'MarketplaceHomepage',
after: toAuditJson(saved),
ipAddress: ip,
userAgent: undefined,
})
return saved
}
export function getPricingConfigs() {
return repo.listPricingConfigs()
}
export async function updatePricingConfigs(entries: { plan: string; billingPeriod: string; amount: number }[], adminId: string, ip?: string) {
const results = await Promise.all(
entries.map((e) => repo.upsertPricingConfig(e.plan, e.billingPeriod, e.amount, adminId)),
)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'PricingConfig',
after: toAuditJson(results),
ipAddress: ip,
userAgent: undefined,
})
return results
}
export function listPlanFeatures() {
return repo.listPlanFeatures()
}
export async function createPlanFeature(data: Parameters<typeof repo.createPlanFeature>[0], adminId: string, ip?: string) {
const feature = await repo.createPlanFeature(data)
await repo.createAuditLog({
adminUserId: adminId,
action: 'CREATE',
resource: 'PlanFeature',
after: toAuditJson(feature),
ipAddress: ip,
userAgent: undefined,
})
return feature
}
export async function updatePlanFeature(id: string, data: Parameters<typeof repo.updatePlanFeature>[1], adminId: string, ip?: string) {
const feature = await repo.updatePlanFeature(id, data)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'PlanFeature',
resourceId: id,
after: toAuditJson(feature),
ipAddress: ip,
userAgent: undefined,
})
return feature
}
export async function deletePlanFeature(id: string, adminId: string, ip?: string) {
await repo.deletePlanFeature(id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'DELETE',
resource: 'PlanFeature',
resourceId: id,
ipAddress: ip,
userAgent: undefined,
})
}
// ─── Promotions ────────────────────────────────────────────────────────────
export function listPromotions() {
return repo.listPromotions()
}
export async function createPromotion(data: Parameters<typeof repo.createPromotion>[0], adminId: string, ip?: string) {
const promo = await repo.createPromotion({ ...data, createdBy: adminId })
await repo.createAuditLog({
adminUserId: adminId, action: 'CREATE', resource: 'PricingPromotion',
after: toAuditJson(promo), ipAddress: ip, userAgent: undefined,
})
return promo
}
export async function updatePromotion(id: string, data: Parameters<typeof repo.updatePromotion>[1], adminId: string, ip?: string) {
const promo = await repo.updatePromotion(id, data)
await repo.createAuditLog({
adminUserId: adminId, action: 'UPDATE', resource: 'PricingPromotion',
after: toAuditJson(promo), ipAddress: ip, userAgent: undefined,
})
return promo
}
export async function deletePromotion(id: string, adminId: string, ip?: string) {
await repo.deletePromotion(id)
await repo.createAuditLog({
adminUserId: adminId, action: 'DELETE', resource: 'PricingPromotion',
entityId: id, ipAddress: ip, userAgent: undefined,
})
}
@@ -0,0 +1,49 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseQuery } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './analytics.service'
import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/summary', async (req, res, next) => {
try {
const { period } = parseQuery(summaryQuerySchema, req)
ok(res, await service.getSummary(req.companyId, period))
} catch (err) { next(err) }
})
router.get('/dashboard', async (req, res, next) => {
try {
ok(res, await service.getDashboard(req.companyId))
} catch (err) { next(err) }
})
router.get('/sources', async (req, res, next) => {
try {
ok(res, await service.getSources(req.companyId))
} catch (err) { next(err) }
})
router.get('/report', requireRole('MANAGER'), async (req, res, next) => {
try {
const query = parseQuery(reportQuerySchema, req)
const result = await service.getReport(req.companyId, query)
if (result.format === 'CSV') {
const start = result.startDate.toISOString().slice(0, 10)
const end = result.endDate.toISOString().slice(0, 10)
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="report-${start}-${end}.csv"`)
return res.send(result.csv)
}
ok(res, result.report)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { reportQuerySchema, summaryQuerySchema } from './analytics.schemas'
describe('analytics schema contracts', () => {
it('defaults summary period to the 30 day window', () => {
expect(summaryQuerySchema.parse({})).toEqual({ period: '30d' })
})
it('defaults reports to JSON while preserving explicit date range filters', () => {
expect(reportQuerySchema.parse({ from: '2026-01-01', to: '2026-01-31' })).toEqual({
from: '2026-01-01',
to: '2026-01-31',
format: 'JSON',
})
})
it('passes CSV format and period through without lowercasing surprises', () => {
expect(reportQuerySchema.parse({ format: 'CSV', period: 'quarter' })).toEqual({
format: 'CSV',
period: 'quarter',
})
})
})
@@ -0,0 +1,12 @@
import { z } from 'zod'
export const summaryQuerySchema = z.object({
period: z.string().default('30d'),
})
export const reportQuerySchema = z.object({
from: z.string().optional(),
to: z.string().optional(),
format: z.string().default('JSON'),
period: z.string().optional(),
})
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
reservation: {
count: vi.fn(),
aggregate: vi.fn(),
findMany: vi.fn(),
groupBy: vi.fn(),
},
vehicle: {
count: vi.fn(),
},
customer: {
count: vi.fn(),
},
subscription: {
findUnique: vi.fn(),
},
accountingSettings: {
findUnique: vi.fn(),
},
},
}))
vi.mock('../../services/financialReportService', () => ({
generateFinancialReport: vi.fn(),
toCsv: vi.fn(),
}))
import { prisma } from '../../lib/prisma'
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
import { getDashboard, getReport, getSources, getSummary } from './analytics.service'
describe('analytics.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
})
it('summarizes reservations, available vehicles, revenue, and customers for a day window', async () => {
vi.mocked(prisma.reservation.count).mockResolvedValue(7 as never)
vi.mocked(prisma.vehicle.count).mockResolvedValue(12 as never)
vi.mocked(prisma.reservation.aggregate).mockResolvedValue({ _sum: { totalAmount: 4800 } } as never)
vi.mocked(prisma.customer.count).mockResolvedValue(42 as never)
const result = await getSummary('company_1', '14d')
expect(prisma.reservation.count).toHaveBeenCalledWith({
where: { companyId: 'company_1', createdAt: { gte: new Date('2026-05-25T12:00:00.000Z') } },
})
expect(prisma.vehicle.count).toHaveBeenCalledWith({ where: { companyId: 'company_1', status: 'AVAILABLE' } })
expect(result).toEqual({
totalReservations: 7,
activeVehicles: 12,
totalRevenue: 4800,
totalCustomers: 42,
period: '14d',
})
})
it('builds dashboard KPIs, percentage deltas, recent reservation cards, source breakdown, and subscription summary', async () => {
vi.mocked(prisma.reservation.count)
.mockResolvedValueOnce(10 as never)
.mockResolvedValueOnce(5 as never)
vi.mocked(prisma.vehicle.count)
.mockResolvedValueOnce(4 as never)
.mockResolvedValueOnce(0 as never)
vi.mocked(prisma.customer.count)
.mockResolvedValueOnce(20 as never)
.mockResolvedValueOnce(25 as never)
vi.mocked(prisma.reservation.aggregate)
.mockResolvedValueOnce({ _sum: { totalAmount: 9000 } } as never)
.mockResolvedValueOnce({ _sum: { totalAmount: 3000 } } as never)
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{
id: 'reservation_abc12345',
contractNumber: null,
customer: { firstName: 'Nora', lastName: 'Driver' },
vehicle: { make: 'Dacia', model: 'Duster' },
startDate: new Date('2026-06-10T00:00:00.000Z'),
endDate: new Date('2026-06-12T00:00:00.000Z'),
status: 'CONFIRMED',
totalAmount: 1200,
},
] as never)
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
{ source: 'MARKETPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
] as never)
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
status: 'ACTIVE',
plan: 'PRO',
trialEndAt: new Date('2026-06-30T00:00:00.000Z'),
} as never)
const result = await getDashboard('company_1')
expect(result.kpis).toEqual({
totalBookings: 10,
activeVehicles: 4,
monthlyRevenue: 9000,
totalCustomers: 20,
bookingsChange: 100,
vehiclesChange: 100,
revenueChange: 200,
customersChange: -20,
})
expect(result.recentReservations).toEqual([expect.objectContaining({
id: 'reservation_abc12345',
bookingRef: 'ABC12345',
customerName: 'Nora Driver',
vehicleName: 'Dacia Duster',
status: 'CONFIRMED',
totalAmount: 1200,
})])
expect(result.sourceBreakdown).toEqual([
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
{ source: 'DIRECT', count: 2, revenue: 0 },
])
expect(result.subscription).toEqual({
status: 'ACTIVE',
planName: 'PRO',
trialEndsAt: new Date('2026-06-30T00:00:00.000Z'),
})
})
it('returns source groups straight from reservation grouping', async () => {
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([{ source: 'DIRECT', _count: { id: 2 } }] as never)
await expect(getSources('company_1')).resolves.toEqual([{ source: 'DIRECT', _count: { id: 2 } }])
expect(prisma.reservation.groupBy).toHaveBeenCalledWith({ by: ['source'], where: { companyId: 'company_1' }, _count: { id: true } })
})
it('uses explicit report dates and emits CSV only when requested', async () => {
vi.mocked(prisma.accountingSettings.findUnique).mockResolvedValue({ reportingPeriod: 'ANNUAL' } as never)
vi.mocked(generateFinancialReport).mockResolvedValue({ rows: [{ label: 'Revenue', amount: 1000 }] } as never)
vi.mocked(toCsv).mockReturnValue('label,amount\nRevenue,1000')
const result = await getReport('company_1', {
from: '2026-05-01',
to: '2026-05-31',
format: 'CSV',
})
expect(generateFinancialReport).toHaveBeenCalledWith('company_1', new Date('2026-05-01'), new Date('2026-05-31'))
expect(toCsv).toHaveBeenCalledWith([{ label: 'Revenue', amount: 1000 }])
expect(result.csv).toBe('label,amount\nRevenue,1000')
})
})
@@ -0,0 +1,100 @@
import { prisma } from '../../lib/prisma'
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
function getRangeFromPeriod(period: string) {
const now = new Date()
switch (period) {
case 'WEEKLY': return { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), to: now }
case 'QUARTERLY': return { from: new Date(now.getFullYear(), now.getMonth() - 2, 1), to: now }
case 'ANNUAL': return { from: new Date(now.getFullYear(), 0, 1), to: now }
default: return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now }
}
}
function pct(current: number, previous: number) {
if (previous === 0) return current > 0 ? 100 : 0
return Math.round(((current - previous) / previous) * 100)
}
export async function getSummary(companyId: string, period: string) {
const days = parseInt(period.replace('d', '')) || 30
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
const [totalReservations, activeVehicles, totalRevenue, totalCustomers] = await Promise.all([
prisma.reservation.count({ where: { companyId, createdAt: { gte: since } } }),
prisma.vehicle.count({ where: { companyId, status: 'AVAILABLE' } }),
prisma.reservation.aggregate({ where: { companyId, status: { in: ['COMPLETED'] }, createdAt: { gte: since } }, _sum: { totalAmount: true } }),
prisma.customer.count({ where: { companyId } }),
])
return { totalReservations, activeVehicles, totalRevenue: totalRevenue._sum.totalAmount ?? 0, totalCustomers, period }
}
export async function getDashboard(companyId: string) {
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const prevMonthEnd = new Date(monthStart.getTime() - 1)
const [
totalBookings, previousBookings, activeVehicles, previousActiveVehicles,
totalCustomers, previousCustomers, monthlyRevenueAgg, previousRevenueAgg,
recentReservations, sourceGroups, subscription,
] = await Promise.all([
prisma.reservation.count({ where: { companyId, createdAt: { gte: monthStart } } }),
prisma.reservation.count({ where: { companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }),
prisma.vehicle.count({ where: { companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }),
prisma.vehicle.count({ where: { companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }),
prisma.customer.count({ where: { companyId } }),
prisma.customer.count({ where: { companyId, createdAt: { lte: prevMonthEnd } } }),
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }),
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }),
prisma.reservation.findMany({ where: { companyId }, include: { customer: true, vehicle: true }, orderBy: { createdAt: 'desc' }, take: 8 }),
prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true }, _sum: { totalAmount: true } }),
prisma.subscription.findUnique({ where: { companyId } }),
])
return {
kpis: {
totalBookings,
activeVehicles,
monthlyRevenue: monthlyRevenueAgg._sum.totalAmount ?? 0,
totalCustomers,
bookingsChange: pct(totalBookings, previousBookings),
vehiclesChange: pct(activeVehicles, previousActiveVehicles),
revenueChange: pct(monthlyRevenueAgg._sum.totalAmount ?? 0, previousRevenueAgg._sum.totalAmount ?? 0),
customersChange: pct(totalCustomers, previousCustomers),
},
recentReservations: (recentReservations as any[]).map((r) => ({
id: r.id,
bookingRef: r.contractNumber ?? r.id.slice(-8).toUpperCase(),
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
vehicleName: `${r.vehicle.make} ${r.vehicle.model}`,
startDate: r.startDate,
endDate: r.endDate,
status: r.status,
totalAmount: r.totalAmount,
})),
sourceBreakdown: (sourceGroups as any[]).map((g) => ({
source: g.source,
count: g._count.id,
revenue: g._sum.totalAmount ?? 0,
})),
subscription: subscription ? {
status: subscription.status,
planName: subscription.plan,
trialEndsAt: subscription.trialEndAt,
} : null,
}
}
export async function getSources(companyId: string) {
return prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true } })
}
export async function getReport(companyId: string, query: { from?: string; to?: string; format: string; period?: string }) {
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId } })
const derivedRange = getRangeFromPeriod(query.period || accountingSettings?.reportingPeriod || 'MONTHLY')
const startDate = query.from ? new Date(query.from) : derivedRange.from
const endDate = query.to ? new Date(query.to) : derivedRange.to
const report = await generateFinancialReport(companyId, startDate, endDate)
return { report, startDate, endDate, format: query.format, csv: query.format === 'CSV' ? toCsv(report.rows) : null }
}
@@ -0,0 +1,24 @@
import { Router } from 'express'
import { parseBody } from '../../http/validate'
import { created, ok } from '../../http/respond'
import { accountStartSchema } from './auth.account.schemas'
import * as service from './auth.account.service'
const router = Router()
/**
* POST /api/v1/auth/account/start
*
* Minimal signup — Step 0 of progressive profiling.
* Creates a DRAFT company + OWNER employee and sends a verification email.
* The user must verify their email before signing in.
*/
router.post('/start', async (req, res, next) => {
try {
const body = parseBody(accountStartSchema, req)
const result = await service.startAccount(body)
created(res, result)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,59 @@
import { z } from 'zod'
/**
* Minimal signup schema — only asks for what's needed to create an identity.
* See progressive-signup-plan.md Section 3.
*/
export const accountStartSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
})
export const companyProfileSchema = z.object({
firstName: z.string().min(1).max(80).optional(),
lastName: z.string().min(1).max(80).optional(),
firstNameAr: z.string().max(80).optional(),
lastNameAr: z.string().max(80).optional(),
companyName: z.string().min(2).max(120).optional(),
companyNameAr: z.string().max(120).optional(),
phone: z.string().min(1).max(80).optional(),
companyEmail: z.string().email().optional(),
streetAddress: z.string().min(1).max(200).optional(),
streetAddressAr: z.string().max(200).optional(),
city: z.string().min(1).max(120).optional(),
cityAr: z.string().max(120).optional(),
country: z.string().min(1).max(120).optional(),
countryAr: z.string().max(120).optional(),
zipCode: z.string().min(1).max(40).optional(),
fax: z.string().max(80).optional(),
yearsActive: z.string().max(80).optional(),
})
export const legalIdentitySchema = z.object({
legalName: z.string().min(2).max(160),
legalForm: z.string().min(1).max(80),
registrationNumber: z.string().min(1).max(120),
iceNumber: z.string().min(1).max(120),
taxId: z.string().min(1).max(120),
operatingLicenseNumber: z.string().min(1).max(120),
operatingLicenseIssuedAt: z.string().min(1).max(40),
operatingLicenseIssuedBy: z.string().min(1).max(160),
})
export const paymentSetupSchema = z.object({
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
responsibleName: z.string().min(1).max(160),
responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: z.string().min(1).max(120),
responsibleQualification: z.string().max(200).optional(),
responsiblePhone: z.string().min(1).max(80),
responsibleEmail: z.string().email(),
representativeName: z.string().max(160).optional(),
representativeTitle: z.string().max(120).optional(),
})
export const planSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
})
@@ -0,0 +1,131 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
type AccountStartInput = output<typeof accountStartSchema>
function ensureDashboardBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
}
}
/**
* Minimal account creation — Step 0 of progressive signup.
* Creates a DRAFT company + OWNER employee, sends a verification email.
* The user must verify their email before they can log in.
*/
export async function startAccount(body: AccountStartInput) {
if (await repo.findEmployeeByEmail(body.email)) {
throw new AppError('An account with this email already exists', 409, 'email_taken')
}
const slug = `company-${Date.now().toString(36)}`
const passwordHash = await bcrypt.hash(body.password, 12)
const verificationToken = crypto.randomBytes(32).toString('hex')
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
const trialEndAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000)
const company = await tx.company.create({
data: {
name: '',
slug,
email: body.email,
address: {},
status: 'TRIALING',
},
})
await tx.brandSettings.create({
data: {
companyId: company.id,
displayName: body.email.split('@')[0] || 'My Workspace',
subdomain: slug,
defaultLocale: body.preferredLanguage,
defaultCurrency: 'MAD',
},
})
await tx.subscription.create({
data: {
companyId: company.id,
plan: 'STARTER',
billingPeriod: 'MONTHLY',
currency: 'MAD',
status: 'TRIALING',
trialStartAt: now,
trialEndAt,
currentPeriodStart: now,
currentPeriodEnd: trialEndAt,
},
})
const employee = await tx.employee.create({
data: {
companyId: company.id,
clerkUserId: `local_owner_${company.id}`,
firstName: '',
lastName: '',
email: body.email,
passwordHash,
emailVerificationToken: verificationToken,
role: 'OWNER',
preferredLanguage: body.preferredLanguage,
isActive: true,
},
include: { company: true },
})
return { employee, company }
})
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
const emailSubjects: Record<string, string> = {
en: 'Verify your email — RentalDriveGo',
fr: 'Vérifiez votre email — RentalDriveGo',
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
}
const emailBodies: Record<string, string> = {
en: `<p>Welcome to RentalDriveGo!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
fr: `<p>Bienvenue sur RentalDriveGo !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
ar: `<p>مرحباً بك في RentalDriveGo!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
}
const emailTexts: Record<string, string> = {
en: `Welcome to RentalDriveGo!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
fr: `Bienvenue sur RentalDriveGo !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
ar: `مرحباً بك في RentalDriveGo!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
}
const lang = (body.preferredLanguage === 'ar' || body.preferredLanguage === 'fr') ? body.preferredLanguage : 'en'
await sendTransactionalEmail({
to: body.email,
subject: emailSubjects[lang],
html: emailBodies[lang],
text: emailTexts[lang],
}).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message)
})
return {
message: 'Account created. Please check your email to verify your address before signing in.',
email: body.email,
}
}
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
import { createCompanySignup } from './auth.company.repo'
function makeDb() {
return {
company: { create: vi.fn().mockResolvedValue({ id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' }) },
brandSettings: { create: vi.fn().mockResolvedValue({}) },
contractSettings: { create: vi.fn().mockResolvedValue({}) },
subscription: { create: vi.fn().mockResolvedValue({}) },
employee: { create: vi.fn().mockResolvedValue({ id: 'employee_1', email: 'owner@example.com' }) },
}
}
const baseInput = {
companyName: 'Atlas Cars',
legalName: 'Atlas Cars SARL',
slug: 'atlas-cars',
ownerEmail: 'owner@example.com',
companyEmail: 'contact@example.com',
companyPhone: '+212600000000',
streetAddress: '1 Fleet Street',
city: 'Casablanca',
country: 'Morocco',
zipCode: '20000',
legalForm: 'SARL',
managerName: 'Aya Benali',
iceNumber: 'ICE123',
taxId: 'TAX123',
operatingLicenseNumber: 'LIC123',
operatingLicenseIssuedAt: '2026-01-01',
operatingLicenseIssuedBy: 'Transport Authority',
fax: '',
yearsActive: '5',
representativeName: '',
representativeTitle: '',
responsibleName: 'Omar Alaoui',
responsibleRole: 'Manager',
responsibleIdentityNumber: 'ID123',
responsibleQualification: '',
responsiblePhone: '+212611111111',
responsibleEmail: 'responsible@example.com',
currency: 'MAD' as const,
registrationNumber: 'REG123',
plan: 'GROWTH' as const,
billingPeriod: 'ANNUAL' as const,
preferredLanguage: 'fr' as const,
firstName: 'Aya',
lastName: 'Benali',
passwordHash: 'hashed-password',
now: new Date('2026-06-01T00:00:00.000Z'),
trialEndAt: new Date('2026-08-30T00:00:00.000Z'),
}
describe('auth.company.repo.createCompanySignup', () => {
it('creates the company, tenant settings, trial subscription, and active owner in one transaction client', async () => {
const db = makeDb()
await expect(createCompanySignup(db as never, baseInput)).resolves.toEqual({
company: { id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' },
employee: { id: 'employee_1', email: 'owner@example.com' },
})
expect(db.company.create).toHaveBeenCalledWith({ data: expect.objectContaining({
name: 'Atlas Cars',
slug: 'atlas-cars',
email: 'contact@example.com',
status: 'TRIALING',
address: expect.objectContaining({
legalName: 'Atlas Cars SARL',
companyEmail: 'contact@example.com',
fax: null,
representativeName: null,
responsibleEmail: 'responsible@example.com',
}),
}) })
expect(db.brandSettings.create).toHaveBeenCalledWith({ data: expect.objectContaining({
companyId: 'company_1',
displayName: 'Atlas Cars',
subdomain: 'atlas-cars',
defaultLocale: 'fr',
defaultCurrency: 'MAD',
}) })
expect(db.contractSettings.create).toHaveBeenCalledWith({ data: expect.objectContaining({
companyId: 'company_1',
registrationNumber: 'REG123',
taxId: 'TAX123',
}) })
expect(db.subscription.create).toHaveBeenCalledWith({ data: expect.objectContaining({
companyId: 'company_1',
plan: 'GROWTH',
billingPeriod: 'ANNUAL',
status: 'TRIALING',
trialStartAt: baseInput.now,
trialEndAt: baseInput.trialEndAt,
}) })
expect(db.employee.create).toHaveBeenCalledWith({ data: expect.objectContaining({
companyId: 'company_1',
clerkUserId: 'local_owner_company_1',
email: 'owner@example.com',
role: 'OWNER',
preferredLanguage: 'fr',
isActive: true,
}) })
})
})
+146
View File
@@ -0,0 +1,146 @@
import { prisma } from '../../lib/prisma'
type DbClient = Pick<typeof prisma, 'company' | 'brandSettings' | 'contractSettings' | 'subscription' | 'employee'>
type CompanySignupInput = {
companyName: string
legalName: string
slug: string
ownerEmail: string
companyEmail: string
companyPhone: string
streetAddress: string
city: string
country: string
zipCode: string
legalForm: string
managerName: string
iceNumber: string
taxId: string
operatingLicenseNumber: string
operatingLicenseIssuedAt: string
operatingLicenseIssuedBy: string
fax?: string
yearsActive: string
representativeName?: string
representativeTitle?: string
responsibleName: string
responsibleRole: string
responsibleIdentityNumber: string
responsibleQualification?: string
responsiblePhone: string
responsibleEmail: string
currency: 'MAD'
registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
preferredLanguage: 'en' | 'fr' | 'ar'
firstName: string
lastName: string
passwordHash: string
now: Date
trialEndAt: Date
}
export function findCompanyBySlug(slug: string) {
return prisma.company.findUnique({ where: { slug } })
}
export function findCompanyByEmail(email: string) {
return prisma.company.findUnique({ where: { email } })
}
export function findEmployeeByEmail(email: string) {
return prisma.employee.findFirst({ where: { email } })
}
export async function createCompanySignup(db: DbClient, input: CompanySignupInput) {
const company = await db.company.create({
data: {
name: input.companyName,
slug: input.slug,
email: input.companyEmail,
phone: input.companyPhone,
address: {
streetAddress: input.streetAddress,
city: input.city,
country: input.country,
zipCode: input.zipCode,
legalName: input.legalName,
legalForm: input.legalForm,
companyEmail: input.companyEmail,
managerName: input.managerName,
iceNumber: input.iceNumber,
operatingLicenseNumber: input.operatingLicenseNumber,
operatingLicenseIssuedAt: input.operatingLicenseIssuedAt,
operatingLicenseIssuedBy: input.operatingLicenseIssuedBy,
fax: input.fax || null,
yearsActive: input.yearsActive,
representativeName: input.representativeName || null,
representativeTitle: input.representativeTitle || null,
responsibleName: input.responsibleName,
responsibleRole: input.responsibleRole,
responsibleIdentityNumber: input.responsibleIdentityNumber,
responsibleQualification: input.responsibleQualification || null,
responsiblePhone: input.responsiblePhone,
responsibleEmail: input.responsibleEmail,
},
status: 'TRIALING',
},
})
await db.brandSettings.create({
data: {
companyId: company.id,
displayName: input.companyName,
subdomain: input.slug,
publicEmail: input.companyEmail,
publicPhone: input.companyPhone,
publicAddress: input.streetAddress,
publicCity: input.city,
publicCountry: input.country,
defaultLocale: input.preferredLanguage,
defaultCurrency: input.currency,
},
})
await db.contractSettings.create({
data: {
companyId: company.id,
legalName: input.legalName,
registrationNumber: input.registrationNumber,
taxId: input.taxId,
},
})
await db.subscription.create({
data: {
companyId: company.id,
plan: input.plan,
billingPeriod: input.billingPeriod,
currency: input.currency,
status: 'TRIALING',
trialStartAt: input.now,
trialEndAt: input.trialEndAt,
currentPeriodStart: input.now,
currentPeriodEnd: input.trialEndAt,
},
})
const employee = await db.employee.create({
data: {
companyId: company.id,
clerkUserId: `local_owner_${company.id}`,
firstName: input.firstName,
lastName: input.lastName,
email: input.ownerEmail,
phone: input.companyPhone,
passwordHash: input.passwordHash,
role: 'OWNER',
preferredLanguage: input.preferredLanguage,
isActive: true,
},
})
return { company, employee }
}
@@ -0,0 +1,26 @@
import { Router } from 'express'
import { parseBody } from '../../http/validate'
import { created } from '../../http/respond'
import { companySignupSchema } from './auth.company.schemas'
import * as service from './auth.company.service'
const router = Router()
router.post('/signup', async (req, res, next) => {
try {
const body = parseBody(companySignupSchema, req)
created(res, await service.signup(body))
} catch (err) { next(err) }
})
router.post('/complete-signup', (_req, res) => {
service.completeSignupDisabled()
res.end()
})
router.post('/verify-email', (_req, res) => {
service.verifyEmailDisabled()
res.end()
})
export default router
@@ -0,0 +1,38 @@
import { z } from 'zod'
export const companySignupSchema = z.object({
firstName: z.string().min(1).max(80),
lastName: z.string().min(1).max(80),
email: z.string().email(),
password: z.string().min(8).max(128),
companyName: z.string().min(2).max(120),
legalName: z.string().min(2).max(160),
legalForm: z.string().min(1).max(80),
registrationNumber: z.string().min(1).max(120),
iceNumber: z.string().min(1).max(120),
taxId: z.string().min(1).max(120),
operatingLicenseNumber: z.string().min(1).max(120),
operatingLicenseIssuedAt: z.string().min(1).max(40),
operatingLicenseIssuedBy: z.string().min(1).max(160),
streetAddress: z.string().min(1).max(200),
city: z.string().min(1).max(120),
country: z.string().min(1).max(120),
zipCode: z.string().min(1).max(40),
companyPhone: z.string().min(1).max(80),
companyEmail: z.string().email(),
fax: z.string().max(80).optional(),
yearsActive: z.string().min(1).max(80),
representativeName: z.string().max(160).optional(),
representativeTitle: z.string().max(120).optional(),
responsibleName: z.string().min(1).max(160),
responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: z.string().min(1).max(120),
responsibleQualification: z.string().max(200).optional(),
responsiblePhone: z.string().min(1).max(80),
responsibleEmail: z.string().email(),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.literal('MAD'),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
})
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import * as repo from './auth.company.repo'
import * as service from './auth.company.service'
import { sendNotification } from '../../services/notificationService'
vi.mock('bcryptjs', () => ({ default: { hash: vi.fn().mockResolvedValue('hashed-password') } }))
vi.mock('../../lib/prisma', () => ({ prisma: { $transaction: vi.fn() } }))
vi.mock('./auth.company.repo', () => ({
findCompanyBySlug: vi.fn(),
findCompanyByEmail: vi.fn(),
findEmployeeByEmail: vi.fn(),
createCompanySignup: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn() }))
const body = {
firstName: 'Aya',
lastName: 'Benali',
email: 'owner@example.com',
password: 'super-secret',
companyName: 'Atlas & Desert Cars!!!',
legalName: 'Atlas Cars SARL',
legalForm: 'SARL',
registrationNumber: 'REG123',
iceNumber: 'ICE123',
taxId: 'TAX123',
operatingLicenseNumber: 'LIC123',
operatingLicenseIssuedAt: '2026-01-01',
operatingLicenseIssuedBy: 'Transport Authority',
streetAddress: '1 Fleet Street',
city: 'Casablanca',
country: 'Morocco',
zipCode: '20000',
companyPhone: '+212600000000',
companyEmail: 'contact@example.com',
yearsActive: '5',
responsibleName: 'Omar Alaoui',
responsibleRole: 'Manager',
responsibleIdentityNumber: 'ID123',
responsiblePhone: '+212611111111',
responsibleEmail: 'responsible@example.com',
preferredLanguage: 'fr' as const,
plan: 'PRO' as const,
billingPeriod: 'MONTHLY' as const,
currency: 'MAD' as const,
paymentProvider: 'PAYPAL' as const,
}
describe('auth.company.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(repo.findCompanyByEmail).mockResolvedValue(null)
vi.mocked(repo.findEmployeeByEmail).mockResolvedValue(null)
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(null)
vi.mocked(repo.createCompanySignup).mockResolvedValue({
company: { id: 'company_1', name: 'Atlas & Desert Cars!!!', slug: 'atlas-desert-cars' },
employee: { id: 'employee_1' },
} as never)
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn({ tx: true }))
vi.mocked(sendNotification).mockResolvedValue([{ channel: 'EMAIL', success: true }] as never)
})
it('rejects duplicate company contact email before hashing or transaction work', async () => {
vi.mocked(repo.findCompanyByEmail).mockResolvedValue({ id: 'company_existing' } as never)
await expect(service.signup(body)).rejects.toMatchObject({ statusCode: 409, error: 'company_email_taken' })
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('rejects duplicate owner email before creating tenant data', async () => {
vi.mocked(repo.findEmployeeByEmail).mockResolvedValue({ id: 'employee_existing' } as never)
await expect(service.signup(body)).rejects.toMatchObject({ statusCode: 409, error: 'owner_email_taken' })
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('generates a stable slug, creates the tenant signup, sends notification, and presents the workspace result', async () => {
const result = await service.signup(body)
expect(result).toEqual(expect.objectContaining({
companyId: 'company_1',
companyName: 'Atlas & Desert Cars!!!',
slug: 'atlas-desert-cars',
nextStep: 'workspace_created',
emailDelivery: { channel: 'EMAIL', success: true },
}))
expect(result.trialEndsAt).toEqual(expect.any(String))
expect(repo.findCompanyBySlug).toHaveBeenCalledWith('atlas-desert-cars')
expect(repo.createCompanySignup).toHaveBeenCalledWith({ tx: true }, expect.objectContaining({
slug: 'atlas-desert-cars',
ownerEmail: 'owner@example.com',
passwordHash: 'hashed-password',
managerName: 'Aya Benali',
trialEndAt: expect.any(Date),
}))
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
type: 'ACCOUNT_CREATED',
companyId: 'company_1',
employeeId: 'employee_1',
email: 'owner@example.com',
channels: ['EMAIL', 'IN_APP'],
locale: 'fr',
templateVariables: expect.objectContaining({
firstName: 'Aya',
companyName: 'Atlas & Desert Cars!!!',
paymentProvider: 'PAYPAL',
}),
}))
})
it('increments the slug when the clean base slug is already taken', async () => {
vi.mocked(repo.findCompanyBySlug)
.mockResolvedValueOnce({ id: 'existing' } as never)
.mockResolvedValueOnce(null)
await service.signup(body)
expect(repo.findCompanyBySlug).toHaveBeenNthCalledWith(1, 'atlas-desert-cars')
expect(repo.findCompanyBySlug).toHaveBeenNthCalledWith(2, 'atlas-desert-cars-2')
expect(repo.createCompanySignup).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ slug: 'atlas-desert-cars-2' }))
})
it('keeps signup successful when notification delivery fails', async () => {
vi.mocked(sendNotification).mockRejectedValue(new Error('smtp down'))
await expect(service.signup(body)).resolves.toMatchObject({
companyId: 'company_1',
emailDelivery: { attempted: false, success: false, error: null },
})
})
it('exposes removed legacy auth endpoints as explicit gone errors', () => {
expect(() => service.completeSignupDisabled()).toThrow(AppError)
expect(() => service.verifyEmailDisabled()).toThrow(AppError)
})
})
@@ -0,0 +1,123 @@
import bcrypt from 'bcryptjs'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { sendNotification } from '../../services/notificationService'
import {
coerceNotificationLocale,
localizeBillingPeriod,
localizePlanName,
} from '../../services/notificationLocalizationService'
import { presentCompanySignup } from './auth.presenter'
import * as repo from './auth.company.repo'
import { companySignupSchema } from './auth.company.schemas'
import type { output } from 'zod'
type CompanySignupInput = output<typeof companySignupSchema>
const TRIAL_PERIOD_DAYS = 90
function slugify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
}
async function generateUniqueSlug(baseName: string) {
const base = slugify(baseName)
for (let attempt = 0; attempt < 25; attempt++) {
const slug = attempt === 0 ? base : `${base}-${attempt + 1}`
const existing = await repo.findCompanyBySlug(slug)
if (!existing) return slug
}
return `${base}-${Date.now().toString(36)}`
}
export async function signup(body: CompanySignupInput) {
if (await repo.findCompanyByEmail(body.companyEmail)) {
throw new AppError('A company account with this contact email already exists', 409, 'company_email_taken')
}
if (await repo.findEmployeeByEmail(body.email)) {
throw new AppError('An employee account with this owner email already exists', 409, 'owner_email_taken')
}
const slug = await generateUniqueSlug(body.companyName)
const now = new Date()
const trialEndAt = new Date(now.getTime() + TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000)
const passwordHash = await bcrypt.hash(body.password, 12)
const result = await prisma.$transaction((tx: any) => repo.createCompanySignup(tx, {
companyName: body.companyName,
legalName: body.legalName,
slug,
ownerEmail: body.email,
companyEmail: body.companyEmail,
companyPhone: body.companyPhone,
streetAddress: body.streetAddress,
city: body.city,
country: body.country,
zipCode: body.zipCode,
legalForm: body.legalForm,
managerName: `${body.firstName} ${body.lastName}`.trim(),
iceNumber: body.iceNumber,
taxId: body.taxId,
operatingLicenseNumber: body.operatingLicenseNumber,
operatingLicenseIssuedAt: body.operatingLicenseIssuedAt,
operatingLicenseIssuedBy: body.operatingLicenseIssuedBy,
fax: body.fax,
yearsActive: body.yearsActive,
representativeName: body.representativeName,
representativeTitle: body.representativeTitle,
responsibleName: body.responsibleName,
responsibleRole: body.responsibleRole,
responsibleIdentityNumber: body.responsibleIdentityNumber,
responsibleQualification: body.responsibleQualification,
responsiblePhone: body.responsiblePhone,
responsibleEmail: body.responsibleEmail,
currency: body.currency,
registrationNumber: body.registrationNumber,
plan: body.plan,
billingPeriod: body.billingPeriod,
preferredLanguage: body.preferredLanguage,
firstName: body.firstName,
lastName: body.lastName,
passwordHash,
now,
trialEndAt,
}))
const lang = coerceNotificationLocale(body.preferredLanguage)
const emailResult = await sendNotification({
type: 'ACCOUNT_CREATED',
companyId: result.company.id,
employeeId: result.employee.id,
email: body.email,
channels: ['EMAIL', 'IN_APP'],
locale: lang,
templateKey: 'account.created',
templateVariables: {
firstName: body.firstName,
companyName: body.companyName,
planName: localizePlanName(body.plan, lang),
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndDate: trialEndAt,
},
}).catch(() => [])
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL')
return presentCompanySignup({
company: result.company,
trialEndAt,
emailDelivery,
})
}
export function completeSignupDisabled() {
throw new AppError('Clerk-based signup has been removed. Use /auth/company/signup instead.', 410, 'disabled')
}
export function verifyEmailDisabled() {
throw new AppError('Email verification resend via Clerk has been removed from this project.', 410, 'disabled')
}
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
employee: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
}))
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './auth.employee.repo'
describe('auth.employee.repo query boundaries', () => {
it('loads employee sessions with company context by id', async () => {
await repo.findEmployeeWithCompanyById('employee_1')
expect(prismaMock.employee.findUnique).toHaveBeenCalledWith({
where: { id: 'employee_1' },
include: { company: true },
})
})
it('looks up employee login emails case-insensitively and includes company context', async () => {
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
include: { company: true },
})
})
it('only sends forgot-password emails to active employees', async () => {
await repo.findActiveEmployeeByEmail('agent@example.test')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: {
email: { equals: 'agent@example.test', mode: 'insensitive' },
isActive: true,
},
})
})
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
await repo.findEmployeeByResetToken('reset_123')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: 'reset_123',
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
},
})
vi.useRealTimers()
})
it('clears stored reset token fields when password changes', async () => {
await repo.resetPassword('employee_1', 'hash_new')
expect(prismaMock.employee.update).toHaveBeenCalledWith({
where: { id: 'employee_1' },
data: {
passwordHash: 'hash_new',
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
})
})
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
employee: {
findFirst: vi.fn(),
},
},
}))
import { prisma } from '../../lib/prisma'
import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './auth.employee.repo'
describe('auth.employee.repo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('looks up employee login email case-insensitively', async () => {
await findEmployeeWithCompanyByEmail('Owner@Example.com')
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
where: {
email: {
equals: 'Owner@Example.com',
mode: 'insensitive',
},
},
include: { company: true },
})
})
it('looks up active employee reset email case-insensitively', async () => {
await findActiveEmployeeByEmail('Owner@Example.com')
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
where: {
email: {
equals: 'Owner@Example.com',
mode: 'insensitive',
},
isActive: true,
},
})
})
})
@@ -0,0 +1,96 @@
import { prisma } from '../../lib/prisma'
export function findEmployeeWithCompanyById(id: string) {
return prisma.employee.findUnique({
where: { id },
include: { company: true },
})
}
export function findEmployeeById(id: string) {
return prisma.employee.findUnique({
where: { id },
})
}
export function findEmployeeWithCompanyByEmail(email: string) {
return prisma.employee.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
include: { company: true },
})
}
export function findActiveEmployeeByEmail(email: string) {
return prisma.employee.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
isActive: true,
},
})
}
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
return prisma.employee.update({
where: { id },
data: { passwordResetToken, passwordResetExpiresAt },
})
}
export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'fr' | 'ar') {
return prisma.employee.update({
where: { id },
data: { preferredLanguage },
})
}
export function findEmployeeByResetToken(token: string) {
return prisma.employee.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
}
export function resetPassword(id: string, passwordHash: string) {
return prisma.employee.update({
where: { id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
}
export function setEmailVerificationToken(id: string, token: string) {
return prisma.employee.update({
where: { id },
data: { emailVerificationToken: token },
})
}
export function findEmployeeByVerificationToken(token: string) {
return prisma.employee.findFirst({
where: { emailVerificationToken: token },
include: { company: true },
})
}
export function verifyEmployeeEmail(id: string) {
return prisma.employee.update({
where: { id },
data: {
emailVerified: new Date(),
emailVerificationToken: null,
},
})
}
@@ -0,0 +1,78 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import { getEmployeeMenu } from '../menu/menu.service'
import {
employeeForgotPasswordSchema,
employeeLanguageSchema,
employeeLoginSchema,
employeeResetPasswordSchema,
} from './auth.employee.schemas'
import * as service from './auth.employee.service'
const router = Router()
router.get('/me', requireCompanyAuth, async (req, res, next) => {
try {
ok(res, await service.getMe(req.employee.id))
} catch (err) { next(err) }
})
router.get('/menu', requireCompanyAuth, async (req, res, next) => {
try {
ok(res, await getEmployeeMenu(req.employee.id))
} catch (err) { next(err) }
})
router.post('/login', async (req, res, next) => {
try {
const body = parseBody(employeeLoginSchema, req)
const result = await service.login(body)
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/logout', (_req, res) => {
clearSessionCookie(res, 'employee')
ok(res, { success: true })
})
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(employeeForgotPasswordSchema, req)
ok(res, await service.forgotPassword(email))
} catch (err) { next(err) }
})
router.patch('/me/language', requireCompanyAuth, async (req, res, next) => {
try {
const { language } = parseBody(employeeLanguageSchema, req)
ok(res, await service.updateLanguage(req.employee.id, language))
} catch (err) { next(err) }
})
router.post('/reset-password', async (req, res, next) => {
try {
const { token, password } = parseBody(employeeResetPasswordSchema, req)
ok(res, await service.resetPassword(token, password))
} catch (err) { next(err) }
})
router.post('/verify-email', async (req, res, next) => {
try {
const { token } = parseBody(employeeResetPasswordSchema.pick({ token: true }), req)
ok(res, await service.verifyEmail(token))
} catch (err) { next(err) }
})
router.post('/resend-verification', async (req, res, next) => {
try {
const { email } = parseBody(employeeForgotPasswordSchema, req)
ok(res, await service.resendVerification(email))
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,19 @@
import { z } from 'zod'
export const employeeLoginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
})
export const employeeForgotPasswordSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
})
export const employeeLanguageSchema = z.object({
language: z.enum(['en', 'fr', 'ar']),
})
export const employeeResetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
})
@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('bcryptjs', () => ({
default: {
compare: vi.fn(),
hash: vi.fn(),
},
}))
vi.mock('../../services/notificationService', () => ({ sendTransactionalEmail: vi.fn() }))
vi.mock('./auth.employee.repo', () => ({
findEmployeeWithCompanyById: vi.fn(),
findEmployeeWithCompanyByEmail: vi.fn(),
findActiveEmployeeByEmail: vi.fn(),
findEmployeeByResetToken: vi.fn(),
findEmployeeById: vi.fn(),
updatePreferredLanguage: vi.fn(),
resetPassword: vi.fn(),
}))
import bcrypt from 'bcryptjs'
import { AppError } from '../../http/errors'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.employee.repo'
import * as service from './auth.employee.service'
const employee = {
id: 'employee_1',
email: 'agent@example.test',
firstName: 'Aya',
lastName: 'Agent',
role: 'AGENT',
preferredLanguage: 'fr',
companyId: 'company_1',
isActive: true,
passwordHash: 'hash_old',
company: { name: 'Atlas Cars', slug: 'atlas' },
}
describe('auth.employee.service edge behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
process.env.JWT_EXPIRY = '8h'
delete process.env.DASHBOARD_URL
delete process.env.NEXT_PUBLIC_DASHBOARD_URL
vi.mocked(bcrypt.compare).mockResolvedValue(true as never)
vi.mocked(bcrypt.hash).mockResolvedValue('hash_new' as never)
})
it('rejects inactive employee sessions before presenting tenant data', async () => {
vi.mocked(repo.findEmployeeWithCompanyById).mockResolvedValue({ ...employee, isActive: false } as never)
await expect(service.getMe('employee_1')).rejects.toMatchObject({
statusCode: 401,
error: 'unauthenticated',
})
})
it('logs in active employees with a signed token and company context', async () => {
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue(employee as never)
const result = await service.login({ email: 'agent@example.test', password: 'correct-password' })
expect(bcrypt.compare).toHaveBeenCalledWith('correct-password', 'hash_old')
expect(result).toMatchObject({
token: expect.any(String),
employee: {
id: 'employee_1',
companyId: 'company_1',
companyName: 'Atlas Cars',
companySlug: 'atlas',
},
})
})
it('distinguishes employees without passwords from wrong credentials', async () => {
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
await expect(service.login({ email: 'agent@example.test', password: 'anything' })).rejects.toMatchObject({
statusCode: 401,
error: 'password_not_set',
})
expect(bcrypt.compare).not.toHaveBeenCalled()
})
it('sends reset links to active employees using the dashboard base path exactly once', async () => {
process.env.DASHBOARD_URL = 'https://tenant.example.test/app'
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(employee as never)
vi.mocked(sendTransactionalEmail).mockResolvedValue({ success: true } as never)
await expect(service.forgotPassword('agent@example.test')).resolves.toEqual({
message: 'If that email is registered, a reset link has been sent.',
})
expect(sendTransactionalEmail).toHaveBeenCalledWith(expect.objectContaining({
to: 'agent@example.test',
subject: expect.any(String),
html: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
text: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
}))
})
it('does not disclose whether forgot-password emails exist', async () => {
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(null)
await expect(service.forgotPassword('missing@example.test')).resolves.toEqual({
message: 'If that email is registered, a reset link has been sent.',
})
expect(sendTransactionalEmail).not.toHaveBeenCalled()
})
it('updates employee password from a stored reset token and clears reset state through the repo', async () => {
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(employee as never)
await expect(service.resetPassword('stored-token', 'new-secure-password')).resolves.toEqual({
message: 'Password updated successfully. You can now sign in.',
})
expect(bcrypt.hash).toHaveBeenCalledWith('new-secure-password', 12)
expect(repo.resetPassword).toHaveBeenCalledWith('employee_1', 'hash_new')
})
it('rejects invalid reset tokens before hashing new passwords', async () => {
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null)
vi.mocked(repo.findEmployeeById).mockResolvedValue(null)
await expect(service.resetPassword('bad-token', 'new-secure-password')).rejects.toMatchObject({
statusCode: 400,
error: 'invalid_token',
})
expect(bcrypt.hash).not.toHaveBeenCalled()
})
it('delegates language preference updates and returns a stable contract', async () => {
vi.mocked(repo.updatePreferredLanguage).mockResolvedValue({} as never)
await expect(service.updateLanguage('employee_1', 'ar')).resolves.toEqual({ language: 'ar' })
expect(repo.updatePreferredLanguage).toHaveBeenCalledWith('employee_1', 'ar')
})
})
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./auth.employee.repo', () => ({
findActiveEmployeeByEmail: vi.fn(),
findEmployeeById: vi.fn(),
findEmployeeByResetToken: vi.fn(),
resetPassword: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
import * as repo from './auth.employee.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword, resetPassword } from './auth.employee.service'
describe('auth.employee.service forgotPassword', () => {
const originalDashboardUrl = process.env.DASHBOARD_URL
const originalJwtSecret = process.env.JWT_SECRET
beforeEach(() => {
vi.clearAllMocks()
process.env.DASHBOARD_URL = 'http://localhost:3000/dashboard'
process.env.JWT_SECRET = 'test-secret'
})
afterAll(() => {
process.env.DASHBOARD_URL = originalDashboardUrl
process.env.JWT_SECRET = originalJwtSecret
})
it('sends the reset email to the canonical stored address', async () => {
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue({
id: 'emp_1',
email: 'owner@company.com',
firstName: 'Aya',
preferredLanguage: 'en',
passwordHash: '$2a$12$abcdefghijklmnopqrstuv',
} as any)
await forgotPassword('Owner@Company.com')
expect(sendTransactionalEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'owner@company.com',
html: expect.stringContaining('/dashboard/reset-password?token='),
text: expect.stringContaining('/dashboard/reset-password?token='),
}),
)
})
it('accepts stateless reset tokens', async () => {
const employee = {
id: 'emp_1',
isActive: true,
passwordHash: '$2a$12$abcdefghijklmnopqrstuv',
} as any
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue({
...employee,
email: 'owner@company.com',
firstName: 'Aya',
preferredLanguage: 'en',
})
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null)
vi.mocked(repo.findEmployeeById).mockResolvedValue(employee)
vi.mocked(repo.resetPassword).mockResolvedValue({} as any)
await forgotPassword('Owner@Company.com')
const emailCall = vi.mocked(sendTransactionalEmail).mock.calls[0]?.[0]
const match = emailCall?.text.match(/token=([^\s]+)/)
expect(match?.[1]).toBeTruthy()
await expect(resetPassword(decodeURIComponent(match![1]), 'new-password-123')).resolves.toEqual({
message: 'Password updated successfully. You can now sign in.',
})
expect(repo.resetPassword).toHaveBeenCalledWith('emp_1', expect.any(String))
})
})
@@ -0,0 +1,231 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import { signActorToken } from '../../security/tokens'
import { AppError } from '../../http/errors'
import { sendTransactionalEmail } from '../../services/notificationService'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.employee.repo'
import { employeeLanguageSchema, employeeLoginSchema } from './auth.employee.schemas'
import type { output } from 'zod'
const RESET_TOKEN_TTL_MINUTES = 60
type EmployeeLoginInput = output<typeof employeeLoginSchema>
type EmployeeLanguageInput = output<typeof employeeLanguageSchema>
type EmployeePasswordResetPayload = jwt.JwtPayload & {
sub: string
type: 'employee_password_reset'
pwdv: string
}
function signEmployeeToken(employeeId: string) {
return signActorToken(employeeId, 'employee', {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
})
}
function getEmployeePasswordResetVersion(passwordHash: string | null | undefined) {
return crypto
.createHash('sha256')
.update(`${process.env.JWT_SECRET!}:${passwordHash ?? 'no-password-set'}`)
.digest('hex')
}
function signEmployeePasswordResetToken(employeeId: string, passwordHash: string | null | undefined) {
return jwt.sign(
{
sub: employeeId,
type: 'employee_password_reset',
pwdv: getEmployeePasswordResetVersion(passwordHash),
},
process.env.JWT_SECRET!,
{
algorithm: 'HS256',
issuer: 'rentaldrivego-api',
audience: 'employee_password_reset',
expiresIn: `${RESET_TOKEN_TTL_MINUTES}m`,
},
)
}
function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'],
issuer: 'rentaldrivego-api',
audience: 'employee_password_reset',
}) as jwt.JwtPayload
if (
typeof payload.sub !== 'string' ||
payload.type !== 'employee_password_reset' ||
typeof payload.pwdv !== 'string'
) {
return null
}
return payload as EmployeePasswordResetPayload
} catch {
return null
}
}
function ensureDashboardBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
}
}
export async function getMe(employeeId: string) {
const employee = await repo.findEmployeeWithCompanyById(employeeId)
if (!employee || !employee.isActive) {
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
}
return presentEmployeeSession(employee)
}
export async function login(body: EmployeeLoginInput) {
const employee = await repo.findEmployeeWithCompanyByEmail(body.email)
if (!employee || !employee.isActive) {
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
}
if (!employee.passwordHash) {
throw new AppError('This account does not have a password yet. Use the invitation or reset email to set one first.', 401, 'password_not_set')
}
if (!employee.emailVerified) {
throw new AppError('Please verify your email address before signing in. Check your inbox for the verification link.', 401, 'email_not_verified')
}
const validPassword = await bcrypt.compare(body.password, employee.passwordHash)
if (!validPassword) {
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
}
return presentEmployeeSession(employee, signEmployeeToken(employee.id))
}
export async function forgotPassword(email: string) {
const employee = await repo.findActiveEmployeeByEmail(email)
if (employee) {
const resetToken = signEmployeePasswordResetToken(employee.id, employee.passwordHash)
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const resetUrl = `${dashboardUrl}/reset-password?token=${encodeURIComponent(resetToken)}`
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
await sendTransactionalEmail({
to: employee.email,
subject: resetPasswordEmail.subject(lang),
html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
}).catch((err) => {
console.error('[ForgotPassword] Email delivery failed:', err?.message)
console.error('[ForgotPassword] Provider config — resendKey present:', !!process.env.RESEND_API_KEY, '| smtpHost:', process.env.MAIL_HOST ?? 'not set')
})
}
return { message: 'If that email is registered, a reset link has been sent.' }
}
export async function updateLanguage(employeeId: string, language: EmployeeLanguageInput['language']) {
await repo.updatePreferredLanguage(employeeId, language)
return { language }
}
async function findEmployeeFromResetToken(token: string) {
try {
const employee = await repo.findEmployeeByResetToken(token)
if (employee) return employee
} catch (err: any) {
console.error('[EmployeeResetPassword] Stored token lookup failed:', err?.message ?? String(err))
}
const payload = verifyEmployeePasswordResetToken(token)
if (!payload) return null
const employee = await repo.findEmployeeById(payload.sub)
if (!employee || !employee.isActive) return null
if (payload.pwdv !== getEmployeePasswordResetVersion(employee.passwordHash)) {
return null
}
return employee
}
export async function resetPassword(token: string, password: string) {
const employee = await findEmployeeFromResetToken(token)
if (!employee) {
throw new AppError('Reset link is invalid or has expired.', 400, 'invalid_token')
}
await repo.resetPassword(employee.id, await bcrypt.hash(password, 12))
return { message: 'Password updated successfully. You can now sign in.' }
}
export async function verifyEmail(token: string) {
const employee = await repo.findEmployeeByVerificationToken(token)
if (!employee) {
throw new AppError('Verification link is invalid or has expired.', 400, 'invalid_token')
}
await repo.verifyEmployeeEmail(employee.id)
return { message: 'Email verified successfully. You can now sign in.', email: employee.email }
}
export async function resendVerification(email: string) {
const employee = await repo.findActiveEmployeeByEmail(email)
if (!employee) {
return { message: 'If that email is registered and not yet verified, a new verification link has been sent.' }
}
if (employee.emailVerified) {
return { message: 'This email is already verified. You can sign in.' }
}
const verificationToken = crypto.randomBytes(32).toString('hex')
await repo.setEmailVerificationToken(employee.id, verificationToken)
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
const lang = (employee.preferredLanguage === 'ar' || employee.preferredLanguage === 'fr') ? employee.preferredLanguage : 'en'
const emailSubjects: Record<string, string> = {
en: 'Verify your email — RentalDriveGo',
fr: 'Vérifiez votre email — RentalDriveGo',
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
}
await sendTransactionalEmail({
to: employee.email,
subject: emailSubjects[lang],
html: `<p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p>`,
text: `Verify your email by visiting:\n${verifyUrl}`,
}).catch((err) => {
console.error('[ResendVerification] Email delivery failed:', err?.message)
})
return { message: 'A new verification link has been sent to your email.' }
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { presentCompanySignup, presentEmployeeSession, presentRenterProfile } from './auth.presenter'
describe('auth presenters', () => {
it('presents employee sessions without leaking company internals or requiring a token', () => {
const employee = {
id: 'employee_1',
email: 'owner@example.com',
firstName: 'Aya',
lastName: 'Benali',
role: 'OWNER',
preferredLanguage: 'fr',
companyId: 'company_1',
company: { name: 'Atlas Cars', slug: 'atlas-cars' },
}
expect(presentEmployeeSession(employee)).toEqual({
employee: {
id: 'employee_1',
email: 'owner@example.com',
firstName: 'Aya',
lastName: 'Benali',
role: 'OWNER',
preferredLanguage: 'fr',
companyId: 'company_1',
companyName: 'Atlas Cars',
companySlug: 'atlas-cars',
},
})
})
it('adds the token only when one is supplied', () => {
const employee = {
id: 'employee_1',
email: 'owner@example.com',
firstName: 'Aya',
lastName: 'Benali',
role: 'OWNER',
preferredLanguage: null,
companyId: 'company_1',
company: { name: 'Atlas Cars', slug: 'atlas-cars' },
}
expect(presentEmployeeSession(employee, 'jwt-token')).toEqual(expect.objectContaining({
token: 'jwt-token',
employee: expect.objectContaining({ id: 'employee_1' }),
}))
})
it('presents company signup with a deterministic next step and fallback email delivery state', () => {
expect(presentCompanySignup({
company: { id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' },
trialEndAt: new Date('2026-09-01T00:00:00.000Z'),
})).toEqual({
companyId: 'company_1',
companyName: 'Atlas Cars',
slug: 'atlas-cars',
invitationId: null,
trialEndsAt: '2026-09-01T00:00:00.000Z',
nextStep: 'workspace_created',
emailDelivery: { attempted: false, success: false, error: null },
})
})
it('hydrates saved renter companies in renter save order and leaves missing brand data explicit', () => {
const renter = {
id: 'renter_1',
firstName: 'Youssef',
lastName: 'Amrani',
email: 'youssef@example.com',
phone: null,
preferredLocale: 'fr',
preferredCurrency: 'MAD',
emailVerified: true,
savedCompanies: [{ companyId: 'company_2' }, { companyId: 'company_missing' }, { companyId: 'company_1' }],
}
const result = presentRenterProfile(renter, [
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: '/atlas.png' } },
{ id: 'company_2', brand: null },
])
expect(result.savedCompanies).toEqual([
{ id: 'company_2', brand: null },
{ id: 'company_missing', brand: null },
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: '/atlas.png' } },
])
})
})
+86
View File
@@ -0,0 +1,86 @@
type EmployeeWithCompany = {
id: string
email: string
firstName: string
lastName: string
role: string
preferredLanguage?: string | null
companyId: string
company: {
name: string
slug: string
}
}
type CompanySignupResult = {
company: {
id: string
name: string
slug: string
}
trialEndAt: Date
emailDelivery?: unknown
}
type RenterProfile = {
id: string
firstName: string
lastName: string
email: string
phone: string | null
preferredLocale: string | null
preferredCurrency: string | null
emailVerified: boolean
savedCompanies: Array<{ companyId: string }>
}
type SavedCompany = {
id: string
brand: {
displayName: string | null
subdomain: string | null
logoUrl: string | null
} | null
}
export function presentEmployeeSession(employee: EmployeeWithCompany, token?: string) {
const data = {
employee: {
id: employee.id,
email: employee.email,
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
preferredLanguage: employee.preferredLanguage,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
},
}
return token ? { token, ...data } : data
}
export function presentCompanySignup(result: CompanySignupResult) {
return {
companyId: result.company.id,
companyName: result.company.name,
slug: result.company.slug,
invitationId: null,
trialEndsAt: result.trialEndAt.toISOString(),
nextStep: 'workspace_created',
emailDelivery: result.emailDelivery ?? { attempted: false, success: false, error: null },
}
}
export function presentRenterProfile(renter: RenterProfile, companies: SavedCompany[]) {
const companyMap = new Map(companies.map((company) => [company.id, company]))
return {
...renter,
savedCompanies: renter.savedCompanies.map((savedCompany) => ({
id: savedCompany.companyId,
brand: companyMap.get(savedCompany.companyId)?.brand ?? null,
})),
}
}
+50
View File
@@ -0,0 +1,50 @@
import { prisma } from '../../lib/prisma'
import type { output } from 'zod'
import { renterUpdateSchema } from './auth.renter.schemas'
type RenterUpdateInput = output<typeof renterUpdateSchema>
export function findRenterProfile(id: string) {
return prisma.renter.findUniqueOrThrow({
where: { id },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
preferredLocale: true,
preferredCurrency: true,
emailVerified: true,
savedCompanies: { select: { companyId: true } },
},
})
}
export function findSavedCompanies(companyIds: string[]) {
return companyIds.length === 0
? Promise.resolve([])
: prisma.company.findMany({
where: { id: { in: companyIds } },
select: {
id: true,
brand: {
select: { displayName: true, subdomain: true, logoUrl: true },
},
},
})
}
export function updateRenterProfile(id: string, data: RenterUpdateInput) {
return prisma.renter.update({
where: { id },
data,
})
}
export function updateRenterFcmToken(id: string, fcmToken: string) {
return prisma.renter.update({
where: { id },
data: { fcmToken },
})
}
@@ -0,0 +1,40 @@
import { Router } from 'express'
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
import * as service from './auth.renter.service'
const router = Router()
router.post('/signup', (_req, res) => {
service.signupDisabled()
res.end()
})
router.post('/login', (_req, res) => {
service.loginDisabled()
res.end()
})
router.get('/me', requireRenterAuth, async (req, res, next) => {
try {
ok(res, await service.getMe(req.renterId))
} catch (err) { next(err) }
})
router.patch('/me', requireRenterAuth, async (req, res, next) => {
try {
const body = parseBody(renterUpdateSchema, req)
ok(res, await service.updateMe(req.renterId, body))
} catch (err) { next(err) }
})
router.post('/me/fcm-token', requireRenterAuth, async (req, res, next) => {
try {
const { fcmToken } = parseBody(renterFcmTokenSchema, req)
ok(res, await service.updateFcmToken(req.renterId, fcmToken))
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,13 @@
import { z } from 'zod'
export const renterUpdateSchema = z.object({
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
phone: z.string().max(30).trim().optional(),
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
preferredCurrency: z.literal('MAD').optional(),
})
export const renterFcmTokenSchema = z.object({
fcmToken: z.string(),
})
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError } from '../../http/errors'
import * as repo from './auth.renter.repo'
import * as service from './auth.renter.service'
vi.mock('./auth.renter.repo', () => ({
findRenterProfile: vi.fn(),
findSavedCompanies: vi.fn(),
updateRenterProfile: vi.fn(),
updateRenterFcmToken: vi.fn(),
}))
describe('auth.renter.service', () => {
beforeEach(() => vi.clearAllMocks())
it('keeps renter self-signup disabled with an explicit product error', () => {
expect(() => service.signupDisabled()).toThrow(AppError)
try {
service.signupDisabled()
} catch (err) {
expect(err).toMatchObject({ statusCode: 403, error: 'renter_signup_disabled' })
}
})
it('keeps renter login disabled with an explicit product error', () => {
expect(() => service.loginDisabled()).toThrow(AppError)
try {
service.loginDisabled()
} catch (err) {
expect(err).toMatchObject({ statusCode: 403, error: 'renter_login_disabled' })
}
})
it('loads saved company brand data only for company IDs attached to the renter profile', async () => {
vi.mocked(repo.findRenterProfile).mockResolvedValue({
id: 'renter_1',
firstName: 'Youssef',
lastName: 'Amrani',
email: 'youssef@example.com',
phone: null,
preferredLocale: 'fr',
preferredCurrency: 'MAD',
emailVerified: true,
savedCompanies: [{ companyId: 'company_2' }, { companyId: 'company_1' }],
} as never)
vi.mocked(repo.findSavedCompanies).mockResolvedValue([
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: null } },
{ id: 'company_2', brand: { displayName: 'Desert Cars', subdomain: 'desert', logoUrl: '/desert.png' } },
] as never)
await expect(service.getMe('renter_1')).resolves.toMatchObject({
id: 'renter_1',
savedCompanies: [
{ id: 'company_2', brand: { displayName: 'Desert Cars' } },
{ id: 'company_1', brand: { displayName: 'Atlas' } },
],
})
expect(repo.findSavedCompanies).toHaveBeenCalledWith(['company_2', 'company_1'])
})
it('delegates profile updates without inventing side effects in the service layer', async () => {
vi.mocked(repo.updateRenterProfile).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never)
await expect(service.updateMe('renter_1', { firstName: 'Nora', preferredLocale: 'ar' })).resolves.toEqual({
id: 'renter_1',
firstName: 'Nora',
})
expect(repo.updateRenterProfile).toHaveBeenCalledWith('renter_1', { firstName: 'Nora', preferredLocale: 'ar' })
})
it('updates the renter FCM token and returns a stable success contract', async () => {
vi.mocked(repo.updateRenterFcmToken).mockResolvedValue({} as never)
await expect(service.updateFcmToken('renter_1', 'fcm_123')).resolves.toEqual({ success: true })
expect(repo.updateRenterFcmToken).toHaveBeenCalledWith('renter_1', 'fcm_123')
})
})
@@ -0,0 +1,30 @@
import { AppError } from '../../http/errors'
import { presentRenterProfile } from './auth.presenter'
import * as repo from './auth.renter.repo'
import type { output } from 'zod'
import { renterUpdateSchema } from './auth.renter.schemas'
type RenterUpdateInput = output<typeof renterUpdateSchema>
export function signupDisabled() {
throw new AppError('Renter account creation is disabled. Only company owners can sign in to the platform.', 403, 'renter_signup_disabled')
}
export function loginDisabled() {
throw new AppError('Renter sign-in is disabled. Only company owners can sign in to the platform.', 403, 'renter_login_disabled')
}
export async function getMe(renterId: string) {
const renter = await repo.findRenterProfile(renterId)
const savedCompanies = await repo.findSavedCompanies(renter.savedCompanies.map((savedCompany: any) => savedCompany.companyId))
return presentRenterProfile(renter, savedCompanies)
}
export function updateMe(renterId: string, body: RenterUpdateInput) {
return repo.updateRenterProfile(renterId, body)
}
export async function updateFcmToken(renterId: string, fcmToken: string) {
await repo.updateRenterFcmToken(renterId, fcmToken)
return { success: true }
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { companySignupSchema } from './auth.company.schemas'
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
const validCompanySignup = {
firstName: 'Aya',
lastName: 'Haddad',
email: 'owner@example.test',
password: 'securePass123',
companyName: 'Atlas Cars',
legalName: 'Atlas Cars SARL',
legalForm: 'SARL',
registrationNumber: 'RC-1',
iceNumber: 'ICE-1',
taxId: 'TAX-1',
operatingLicenseNumber: 'LIC-1',
operatingLicenseIssuedAt: '2025-01-01',
operatingLicenseIssuedBy: 'Transport Authority',
streetAddress: '1 Main Street',
city: 'Casablanca',
country: 'Morocco',
zipCode: '20000',
companyPhone: '+212600000000',
companyEmail: 'company@example.test',
yearsActive: '3',
responsibleName: 'Aya Haddad',
responsibleRole: 'Owner',
responsibleIdentityNumber: 'ID-1',
responsiblePhone: '+212600000001',
responsibleEmail: 'responsible@example.test',
plan: 'GROWTH',
billingPeriod: 'MONTHLY',
currency: 'MAD',
paymentProvider: 'AMANPAY',
}
describe('role-specific auth schema contracts', () => {
it('defaults company signup language and keeps billing provider constrained', () => {
expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en', paymentProvider: 'AMANPAY' })
expect(() => companySignupSchema.parse({ ...validCompanySignup, paymentProvider: 'WIRE_TRANSFER' })).toThrow()
expect(() => companySignupSchema.parse({ ...validCompanySignup, currency: 'EUR' })).toThrow()
})
it('normalizes employee email fields and validates reset password strength', () => {
expect(employeeLoginSchema.parse({ email: 'OWNER@EXAMPLE.TEST', password: 'secret' })).toEqual({
email: 'owner@example.test',
password: 'secret',
})
expect(employeeForgotPasswordSchema.parse({ email: 'HELP@EXAMPLE.TEST' })).toEqual({ email: 'help@example.test' })
expect(employeeResetPasswordSchema.parse({ token: 'token_1', password: 'new-pass-123' })).toEqual({ token: 'token_1', password: 'new-pass-123' })
expect(() => employeeResetPasswordSchema.parse({ token: '', password: 'short' })).toThrow()
})
it('limits employee language changes to supported locales', () => {
expect(employeeLanguageSchema.parse({ language: 'fr' })).toEqual({ language: 'fr' })
expect(() => employeeLanguageSchema.parse({ language: 'es' })).toThrow()
})
it('normalizes renter profile updates while keeping MAD as the only supported currency', () => {
expect(renterUpdateSchema.parse({ firstName: ' Salma ', lastName: ' Idrissi ', preferredLocale: 'ar', preferredCurrency: 'MAD' })).toEqual({
firstName: 'Salma',
lastName: 'Idrissi',
preferredLocale: 'ar',
preferredCurrency: 'MAD',
})
expect(() => renterUpdateSchema.parse({ preferredCurrency: 'EUR' })).toThrow()
})
it('requires a renter FCM token payload key', () => {
expect(renterFcmTokenSchema.parse({ fcmToken: 'token_abc' })).toEqual({ fcmToken: 'token_abc' })
expect(() => renterFcmTokenSchema.parse({ token: 'token_abc' })).toThrow()
})
})
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { companySignupSchema } from './auth.company.schemas'
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
describe('auth schemas', () => {
const validCompanySignup = {
firstName: 'Aya',
lastName: 'Benali',
email: 'owner@example.test',
password: 'safe-password',
companyName: 'Atlas Cars',
legalName: 'Atlas Cars SARL',
legalForm: 'SARL',
registrationNumber: 'RC-1',
iceNumber: 'ICE-1',
taxId: 'TAX-1',
operatingLicenseNumber: 'LIC-1',
operatingLicenseIssuedAt: '2024-01-01',
operatingLicenseIssuedBy: 'Rabat',
streetAddress: '1 Avenue',
city: 'Rabat',
country: 'Morocco',
zipCode: '10000',
companyPhone: '+212600000000',
companyEmail: 'company@example.test',
yearsActive: '3',
responsibleName: 'Aya Benali',
responsibleRole: 'Owner',
responsibleIdentityNumber: 'ID-1',
responsiblePhone: '+212600000001',
responsibleEmail: 'owner@example.test',
plan: 'GROWTH',
billingPeriod: 'MONTHLY',
currency: 'MAD',
paymentProvider: 'AMANPAY',
} as const
it('defaults company signup language and rejects unsupported commercial choices', () => {
const parsed = companySignupSchema.parse(validCompanySignup)
expect(parsed.preferredLanguage).toBe('en')
expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(false)
expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false)
expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false)
})
it('normalizes employee auth fields and rejects weak reset payloads', () => {
expect(employeeLoginSchema.parse({ email: 'Agent@Example.COM', password: 'password' })).toEqual({
email: 'agent@example.com',
password: 'password',
})
expect(employeeForgotPasswordSchema.parse({ email: 'Reset@Example.COM' }).email).toBe('reset@example.com')
expect(employeeLanguageSchema.safeParse({ language: 'ar' }).success).toBe(true)
expect(employeeLanguageSchema.safeParse({ language: 'es' }).success).toBe(false)
expect(employeeResetPasswordSchema.safeParse({ token: '', password: 'long-enough' }).success).toBe(false)
expect(employeeResetPasswordSchema.safeParse({ token: 'token_1', password: 'short' }).success).toBe(false)
})
it('trims renter profile updates and keeps renter push token required', () => {
expect(renterUpdateSchema.parse({ firstName: ' Aya ', lastName: ' Benali ', phone: ' 0600000000 ', preferredLocale: 'fr', preferredCurrency: 'MAD' })).toEqual({
firstName: 'Aya',
lastName: 'Benali',
phone: '0600000000',
preferredLocale: 'fr',
preferredCurrency: 'MAD',
})
expect(renterUpdateSchema.safeParse({ preferredLocale: 'es' }).success).toBe(false)
expect(renterUpdateSchema.safeParse({ preferredCurrency: 'EUR' }).success).toBe(false)
expect(renterFcmTokenSchema.safeParse({ fcmToken: 'token_1' }).success).toBe(true)
expect(renterFcmTokenSchema.safeParse({}).success).toBe(false)
})
})
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest'
import { presentBrand } from './company.presenter'
const fullBrand = {
id: 'brand_1',
companyId: 'comp_1',
displayName: 'Test Rentals',
subdomain: 'test-rentals',
logoUrl: 'https://example.com/logo.jpg',
primaryColor: '#1A56DB',
defaultLocale: 'en',
defaultCurrency: 'MAD',
amanpayMerchantId: 'merchant-abc',
amanpaySecretKey: 'super-secret-key',
paypalEmail: 'paypal@example.com',
paypalMerchantId: 'paypal-merchant-xyz',
isListedOnMarketplace: true,
}
describe('presentBrand', () => {
it('strips amanpaySecretKey from the response', () => {
const result = presentBrand(fullBrand)
expect(result).not.toHaveProperty('amanpaySecretKey')
})
it('strips amanpayMerchantId from the response', () => {
const result = presentBrand(fullBrand)
expect(result).not.toHaveProperty('amanpayMerchantId')
})
it('strips paypalEmail from the response', () => {
const result = presentBrand(fullBrand)
expect(result).not.toHaveProperty('paypalEmail')
})
it('strips paypalMerchantId from the response', () => {
const result = presentBrand(fullBrand)
expect(result).not.toHaveProperty('paypalMerchantId')
})
it('sets amanpayConfigured=true when both amanpay credentials are present', () => {
const result = presentBrand(fullBrand)
expect(result.amanpayConfigured).toBe(true)
})
it('sets amanpayConfigured=false when amanpaySecretKey is null', () => {
const result = presentBrand({ ...fullBrand, amanpaySecretKey: null })
expect(result.amanpayConfigured).toBe(false)
})
it('sets amanpayConfigured=false when amanpayMerchantId is null', () => {
const result = presentBrand({ ...fullBrand, amanpayMerchantId: null })
expect(result.amanpayConfigured).toBe(false)
})
it('sets amanpayConfigured=false when both amanpay fields are null', () => {
const result = presentBrand({ ...fullBrand, amanpayMerchantId: null, amanpaySecretKey: null })
expect(result.amanpayConfigured).toBe(false)
})
it('sets paypalConfigured=true when paypalEmail is set', () => {
const result = presentBrand({ ...fullBrand, paypalMerchantId: null })
expect(result.paypalConfigured).toBe(true)
})
it('sets paypalConfigured=true when paypalMerchantId is set', () => {
const result = presentBrand({ ...fullBrand, paypalEmail: null })
expect(result.paypalConfigured).toBe(true)
})
it('sets paypalConfigured=false when both paypal fields are null', () => {
const result = presentBrand({ ...fullBrand, paypalEmail: null, paypalMerchantId: null })
expect(result.paypalConfigured).toBe(false)
})
it('preserves all non-credential fields', () => {
const result = presentBrand(fullBrand)
expect(result).toMatchObject({
id: 'brand_1',
companyId: 'comp_1',
displayName: 'Test Rentals',
subdomain: 'test-rentals',
logoUrl: 'https://example.com/logo.jpg',
primaryColor: '#1A56DB',
defaultLocale: 'en',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
})
})
it('returns null when brand is null', () => {
expect(presentBrand(null)).toBeNull()
})
it('returns undefined when brand is undefined', () => {
expect(presentBrand(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,13 @@
export function presentCompany(company: any) {
return company
}
export function presentBrand(brand: any) {
if (!brand) return brand
const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand
return {
...safe,
amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey),
paypalConfigured: !!(paypalEmail || paypalMerchantId),
}
}
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
company: { findUniqueOrThrow: vi.fn(), update: vi.fn(), findFirst: vi.fn() },
brandSettings: { findUnique: vi.fn(), upsert: vi.fn(), findFirst: vi.fn(), updateMany: vi.fn() },
contractSettings: { findUnique: vi.fn(), upsert: vi.fn() },
insurancePolicy: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
pricingRule: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
accountingSettings: { findUnique: vi.fn(), upsert: vi.fn() },
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './company.repo'
describe('company.repo edge queries', () => {
beforeEach(() => vi.clearAllMocks())
it('loads a company dashboard envelope with brand, subscription and counts', async () => {
await repo.findCompany('company_1')
expect(prisma.company.findUniqueOrThrow).toHaveBeenCalledWith({
where: { id: 'company_1' },
include: {
brand: true,
subscription: true,
_count: { select: { vehicles: true, customers: true, reservations: true } },
},
})
})
it('upserts brand settings with companyId only in the create branch', async () => {
await repo.upsertBrand('company_1', { logoUrl: '/new.png' }, { primaryColor: '#111111' })
expect(prisma.brandSettings.upsert).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
update: { logoUrl: '/new.png' },
create: { companyId: 'company_1', primaryColor: '#111111' },
})
})
it('orders insurance policies by required status, sort order and name', async () => {
await repo.findInsurancePolicies('company_1')
expect(prisma.insurancePolicy.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
})
it('updates and deletes pricing rules inside the tenant boundary', async () => {
await repo.updatePricingRule('rule_1', 'company_1', { isActive: false })
await repo.deletePricingRule('rule_1', 'company_1')
expect(prisma.pricingRule.updateMany).toHaveBeenCalledWith({
where: { id: 'rule_1', companyId: 'company_1' },
data: { isActive: false },
})
expect(prisma.pricingRule.deleteMany).toHaveBeenCalledWith({ where: { id: 'rule_1', companyId: 'company_1' } })
})
it('detects duplicate public branding outside the current company', async () => {
await repo.findBrandBySubdomain('atlas', 'company_1')
await repo.findBrandByCustomDomain('cars.example.test', 'company_1')
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(1, {
where: { subdomain: 'atlas', companyId: { not: 'company_1' } },
})
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(2, {
where: { customDomain: 'cars.example.test', companyId: { not: 'company_1' } },
})
})
it('clears custom domain verification metadata in one tenant-scoped mutation', async () => {
await repo.clearCustomDomain('company_1')
expect(prisma.brandSettings.updateMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
})
})
})
+145
View File
@@ -0,0 +1,145 @@
import { prisma } from '../../lib/prisma'
import { generateCompanyApiKey } from '../../security/apiKeys'
export async function findCompany(companyId: string) {
return prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: {
brand: true,
subscription: true,
_count: { select: { vehicles: true, customers: true, reservations: true } },
},
})
}
export async function updateCompany(companyId: string, data: any) {
return prisma.company.update({ where: { id: companyId }, data })
}
export async function findBrand(companyId: string) {
return prisma.brandSettings.findUnique({ where: { companyId } })
}
export async function upsertBrand(companyId: string, updateData: any, createData: any) {
return prisma.brandSettings.upsert({
where: { companyId },
update: updateData,
create: { companyId, ...createData },
})
}
export async function findContractSettings(companyId: string) {
return prisma.contractSettings.findUnique({ where: { companyId } })
}
export async function upsertContractSettings(companyId: string, data: any) {
return prisma.contractSettings.upsert({
where: { companyId },
update: data,
create: { companyId, ...data },
})
}
export async function findInsurancePolicies(companyId: string) {
return prisma.insurancePolicy.findMany({
where: { companyId },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
}
export async function createInsurancePolicy(companyId: string, data: any) {
return prisma.insurancePolicy.create({ data: { companyId, ...data } })
}
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
return prisma.insurancePolicy.updateMany({ where: { id, companyId }, data })
}
export async function findInsurancePolicyOrThrow(id: string) {
return prisma.insurancePolicy.findUniqueOrThrow({ where: { id } })
}
export async function deleteInsurancePolicy(id: string, companyId: string) {
return prisma.insurancePolicy.deleteMany({ where: { id, companyId } })
}
export async function findPricingRules(companyId: string) {
return prisma.pricingRule.findMany({
where: { companyId },
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
})
}
export async function createPricingRule(companyId: string, data: any) {
return prisma.pricingRule.create({ data: { companyId, ...data } })
}
export async function updatePricingRule(id: string, companyId: string, data: any) {
return prisma.pricingRule.updateMany({ where: { id, companyId }, data })
}
export async function findPricingRuleOrThrow(id: string) {
return prisma.pricingRule.findUniqueOrThrow({ where: { id } })
}
export async function deletePricingRule(id: string, companyId: string) {
return prisma.pricingRule.deleteMany({ where: { id, companyId } })
}
export async function findAccountingSettings(companyId: string) {
return prisma.accountingSettings.findUnique({ where: { companyId } })
}
export async function upsertAccountingSettings(companyId: string, data: any) {
return prisma.accountingSettings.upsert({
where: { companyId },
update: data,
create: { companyId, ...data },
})
}
export async function findApiKey(companyId: string) {
const apiKeys = await (prisma as any).companyApiKey.findMany({
where: { companyId, revokedAt: null },
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true, revokedAt: true },
})
return { apiKeys }
}
export async function regenerateApiKey(companyId: string) {
const nextKey = generateCompanyApiKey()
await prisma.$transaction(async (tx: any) => {
await (tx as any).companyApiKey.updateMany({
where: { companyId, revokedAt: null },
data: { revokedAt: new Date() },
})
await (tx as any).companyApiKey.create({
data: {
companyId,
name: 'Default API key',
prefix: nextKey.prefix,
keyHash: nextKey.keyHash,
},
})
})
return { apiKey: nextKey.rawKey, prefix: nextKey.prefix, warning: 'This raw API key is shown once. Store it securely.' }
}
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
return prisma.brandSettings.findFirst({ where: { subdomain, companyId: { not: excludeCompanyId } } })
}
export async function findBrandByCustomDomain(customDomain: string, excludeCompanyId: string) {
return prisma.brandSettings.findFirst({ where: { customDomain, companyId: { not: excludeCompanyId } } })
}
export async function clearCustomDomain(companyId: string) {
return prisma.brandSettings.updateMany({
where: { companyId },
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
})
}
+205
View File
@@ -0,0 +1,205 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { requireCompanyPolicy } from '../../middleware/requireCompanyPolicy'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created, noContent } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
import * as service from './company.service'
import {
companySchema, brandSchema, contractSettingsSchema,
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
subdomainSchema, customDomainSchema, idParamSchema,
} from './company.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/me', async (req, res, next) => {
try {
const company = await service.getCompany(req.companyId)
ok(res, company)
} catch (err) { next(err) }
})
router.patch('/me', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(companySchema, req)
const company = await service.updateCompany(req.companyId, body)
ok(res, company)
} catch (err) { next(err) }
})
router.get('/me/brand', async (req, res, next) => {
try {
const brand = await service.getBrand(req.companyId)
ok(res, brand)
} catch (err) { next(err) }
})
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(brandSchema, req)
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
ok(res, brand)
} catch (err) { next(err) }
})
router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
try {
assertImageFile(req.file, 'logo')
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
ok(res, brand)
} catch (err) { next(err) }
})
router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
try {
assertImageFile(req.file, 'hero image')
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
ok(res, brand)
} catch (err) { next(err) }
})
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
try {
const { subdomain } = parseBody(subdomainSchema, req)
const result = await service.checkSubdomainAvailability(subdomain, req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
try {
const { customDomain } = parseBody(customDomainSchema, req)
const brand = await service.setCustomDomain(req.companyId, req.company.name, req.company.slug, customDomain)
ok(res, brand)
} catch (err) { next(err) }
})
router.get('/me/brand/custom-domain/status', async (req, res, next) => {
try {
const status = await service.getCustomDomainStatus(req.companyId)
ok(res, status)
} catch (err) { next(err) }
})
router.delete('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
try {
const result = await service.removeCustomDomain(req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
router.get('/me/contract-settings', async (req, res, next) => {
try {
const settings = await service.getContractSettings(req.companyId)
ok(res, settings)
} catch (err) { next(err) }
})
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(contractSettingsSchema, req)
const settings = await service.updateContractSettings(req.companyId, body)
ok(res, settings)
} catch (err) { next(err) }
})
router.get('/me/insurance-policies', async (req, res, next) => {
try {
const policies = await service.getInsurancePolicies(req.companyId)
ok(res, policies)
} catch (err) { next(err) }
})
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(insurancePolicySchema, req)
const policy = await service.createInsurancePolicy(req.companyId, body)
created(res, policy)
} catch (err) { next(err) }
})
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(insurancePolicySchema.partial(), req)
const policy = await service.updateInsurancePolicy(id, req.companyId, body)
ok(res, policy)
} catch (err) { next(err) }
})
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteInsurancePolicy(id, req.companyId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.get('/me/pricing-rules', async (req, res, next) => {
try {
const rules = await service.getPricingRules(req.companyId)
ok(res, rules)
} catch (err) { next(err) }
})
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(pricingRuleSchema, req)
const rule = await service.createPricingRule(req.companyId, body)
created(res, rule)
} catch (err) { next(err) }
})
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(pricingRuleSchema.partial(), req)
const rule = await service.updatePricingRule(id, req.companyId, body)
ok(res, rule)
} catch (err) { next(err) }
})
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deletePricingRule(id, req.companyId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.get('/me/accounting-settings', async (req, res, next) => {
try {
const settings = await service.getAccountingSettings(req.companyId)
ok(res, settings)
} catch (err) { next(err) }
})
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
try {
const body = parseBody(accountingSettingsSchema, req)
const settings = await service.updateAccountingSettings(req.companyId, body)
ok(res, settings)
} catch (err) { next(err) }
})
router.get('/me/api-key', requireCompanyPolicy('viewApiKey'), async (req, res, next) => {
try {
const company = await service.getApiKey(req.companyId)
ok(res, company)
} catch (err) { next(err) }
})
router.post('/me/api-key/regenerate', requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => {
try {
const company = await service.regenerateApiKey(req.companyId)
ok(res, company)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { accountingSettingsSchema, brandSchema, companySchema, contractSettingsSchema, customDomainSchema, insurancePolicySchema, pricingRuleSchema, subdomainSchema } from './company.schemas'
describe('company schemas edge cases', () => {
it('validates company and brand public configuration boundaries', () => {
expect(companySchema.parse({ email: 'ops@example.test', address: { city: 'Rabat' } })).toMatchObject({ email: 'ops@example.test' })
expect(companySchema.safeParse({ email: 'bad-email' }).success).toBe(false)
const brand = brandSchema.parse({
displayName: 'Atlas',
websiteUrl: 'https://atlas.example.test',
paypalEmail: 'paypal@example.test',
defaultCurrency: 'MAD',
homePageConfig: { showOffers: true, layout: { items: [{ id: 'hero-1', type: 'hero', x: 1, y: 1, w: 12, h: 4 }] } },
})
expect(brand.homePageConfig?.layout?.items[0].type).toBe('hero')
expect(brandSchema.safeParse({ websiteUrl: 'not-url' }).success).toBe(false)
expect(brandSchema.safeParse({ defaultCurrency: 'EUR' }).success).toBe(false)
})
it('keeps contract, accounting, insurance, and pricing settings inside known enums', () => {
expect(contractSettingsSchema.parse({ fuelPolicyType: 'FULL_TO_FULL', additionalDriverCharge: 'PER_DAY', taxRate: 20 })).toMatchObject({ fuelPolicyType: 'FULL_TO_FULL' })
expect(contractSettingsSchema.safeParse({ fuelPolicyType: 'CHAOS' }).success).toBe(false)
expect(accountingSettingsSchema.parse({ reportingPeriod: 'MONTHLY', fiscalYearStart: 1, currency: 'MAD' })).toMatchObject({ fiscalYearStart: 1 })
expect(accountingSettingsSchema.safeParse({ fiscalYearStart: 13 }).success).toBe(false)
expect(insurancePolicySchema.parse({ name: 'CDW', type: 'CDW', chargeType: 'PER_DAY', chargeValue: 100 })).toMatchObject({ isActive: true, isRequired: false, sortOrder: 0 })
expect(insurancePolicySchema.safeParse({ name: 'CDW', type: 'CDW', chargeType: 'PER_DAY', chargeValue: -1 }).success).toBe(false)
expect(pricingRuleSchema.safeParse({ name: 'Young driver', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 10 }).success).toBe(true)
expect(pricingRuleSchema.safeParse({ name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 10 }).success).toBe(false)
})
it('requires minimal custom domain and subdomain lengths', () => {
expect(subdomainSchema.safeParse({ subdomain: 'ab' }).success).toBe(false)
expect(customDomainSchema.safeParse({ customDomain: 'x.io' }).success).toBe(true)
})
})
@@ -0,0 +1,159 @@
import { z } from 'zod'
import { optionalTextField, optionalTitleCaseAllField, optionalUpperField, optionalEmailField, optionalPhoneField } from '../../lib/zodValidation'
export const companySchema = z.object({
name: optionalTextField('commercialName'),
email: optionalEmailField(),
phone: optionalPhoneField(),
address: z.record(z.unknown()).optional(),
})
export const brandSchema = z.object({
displayName: z.string().min(1).optional(),
tagline: z.string().optional(),
primaryColor: z.string().optional(),
accentColor: z.string().optional(),
publicEmail: optionalEmailField(),
publicPhone: optionalPhoneField(),
publicAddress: z.string().optional(),
publicCity: optionalTextField('city'),
publicCountry: optionalTextField('country'),
websiteUrl: z.string().url().optional(),
whatsappNumber: z.string().optional(),
defaultLocale: z.string().optional(),
defaultCurrency: z.literal('MAD').optional(),
amanpayMerchantId: z.string().optional(),
amanpaySecretKey: z.string().optional(),
paypalEmail: optionalEmailField(),
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(),
})
export const contractSettingsSchema = z.object({
legalName: optionalTitleCaseAllField('legalCompanyName'),
registrationNumber: optionalUpperField('commercialRegistry'),
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(),
})
export 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),
})
export 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(),
})
export const accountingSettingsSchema = z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).optional(),
currency: z.literal('MAD').optional(),
accountantEmail: optionalEmailField(),
accountantName: optionalTextField('name'),
autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
})
export const subdomainSchema = z.object({ subdomain: z.string().min(3) })
export const customDomainSchema = z.object({ customDomain: z.string().min(3) })
export const idParamSchema = z.object({ id: z.string() })
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('/storage/companies/company_1/brand/logo.jpg') }))
vi.mock('./company.repo', () => ({
findCompany: vi.fn(),
updateCompany: vi.fn(),
findBrand: vi.fn(),
upsertBrand: vi.fn(),
findBrandBySubdomain: vi.fn(),
findBrandByCustomDomain: vi.fn(),
clearCustomDomain: vi.fn(),
findContractSettings: vi.fn(),
upsertContractSettings: vi.fn(),
findInsurancePolicies: vi.fn(),
createInsurancePolicy: vi.fn(),
updateInsurancePolicy: vi.fn(),
findInsurancePolicyOrThrow: vi.fn(),
deleteInsurancePolicy: vi.fn(),
findPricingRules: vi.fn(),
createPricingRule: vi.fn(),
updatePricingRule: vi.fn(),
findPricingRuleOrThrow: vi.fn(),
deletePricingRule: vi.fn(),
findAccountingSettings: vi.fn(),
upsertAccountingSettings: vi.fn(),
findApiKey: vi.fn(),
regenerateApiKey: vi.fn(),
}))
const repo = await import('./company.repo')
const service = await import('./company.service')
const currentBrand = {
id: 'brand_1',
companyId: 'company_1',
displayName: 'Atlas Cars',
subdomain: 'atlas',
amanpayMerchantId: 'merchant_1',
amanpaySecretKey: 'secret_1',
paypalEmail: null,
paypalMerchantId: null,
paymentMethodsEnabled: ['AMANPAY'],
}
beforeEach(() => {
vi.clearAllMocks()
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
})
describe('company.service edge behavior', () => {
it('preserves existing AmanPay credentials when recomputing enabled payment methods', async () => {
vi.mocked(repo.findBrand).mockResolvedValue(currentBrand as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, paypalEmail: 'billing@example.test' } as any)
const result = await service.updateBrand('company_1', { paypalEmail: 'billing@example.test' }, 'Atlas Cars', 'atlas')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({
paypalEmail: 'billing@example.test',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
}),
expect.objectContaining({
displayName: 'Atlas Cars',
subdomain: 'atlas',
paypalEmail: 'billing@example.test',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
}),
)
expect(result).not.toHaveProperty('paypalEmail')
expect(result.paypalConfigured).toBe(true)
})
it('does not report AmanPay enabled when only one credential is available', async () => {
vi.mocked(repo.findBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any)
await service.updateBrand('company_1', {}, 'Atlas Cars', 'atlas')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({ paymentMethodsEnabled: [] }),
expect.objectContaining({ paymentMethodsEnabled: [] }),
)
})
it('normalizes custom domains and marks them pending verification', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ id: 'brand_1', customDomain: 'cars.example.com' } as any)
await service.setCustomDomain('company_1', 'Atlas Cars', 'atlas', ' CARS.Example.COM ')
expect(repo.findBrandByCustomDomain).toHaveBeenCalledWith('cars.example.com', 'company_1')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({ customDomain: 'cars.example.com', customDomainVerified: false, customDomainAddedAt: expect.any(Date) }),
expect.objectContaining({ displayName: 'Atlas Cars', subdomain: 'atlas', customDomain: 'cars.example.com', customDomainVerified: false, customDomainAddedAt: expect.any(Date) }),
)
})
it('rejects custom domains already owned by another company', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue({ id: 'other_brand' } as any)
await expect(service.setCustomDomain('company_1', 'Atlas Cars', 'atlas', 'cars.example.com')).rejects.toMatchObject({ statusCode: 409 })
expect(repo.upsertBrand).not.toHaveBeenCalled()
})
it('returns deterministic custom-domain status and honors configured DNS target', async () => {
process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET = 'tenant-target.example.net'
vi.mocked(repo.findBrand).mockResolvedValue({ customDomain: 'cars.example.com', customDomainVerified: false } as any)
await expect(service.getCustomDomainStatus('company_1')).resolves.toEqual({
customDomain: 'cars.example.com',
verified: false,
status: 'pending_dns',
dnsTarget: 'tenant-target.example.net',
})
})
it('raises not found when updating a missing insurance policy', async () => {
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
await expect(service.updateInsurancePolicy('policy_1', 'company_1', { name: 'CDW' })).rejects.toMatchObject({ statusCode: 404 })
expect(repo.findInsurancePolicyOrThrow).not.toHaveBeenCalled()
})
it('raises not found when deleting a missing pricing rule', async () => {
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
await expect(service.deletePricingRule('rule_1', 'company_1')).rejects.toMatchObject({ statusCode: 404 })
})
})
@@ -0,0 +1,153 @@
import { uploadImage } from '../../lib/storage'
import { ConflictError, NotFoundError } from '../../http/errors'
import { presentCompany, presentBrand } from './company.presenter'
import * as repo from './company.repo'
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
}
export async function getCompany(companyId: string) {
return presentCompany(await repo.findCompany(companyId))
}
export async function updateCompany(companyId: string, data: any) {
return presentCompany(await repo.updateCompany(companyId, data))
}
export async function getBrand(companyId: string) {
return presentBrand(await repo.findBrand(companyId))
}
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
const current = await repo.findBrand(companyId)
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey,
paypalEmail: body.paypalEmail ?? current?.paypalEmail,
})
return presentBrand(await repo.upsertBrand(
companyId,
{ ...body, paymentMethodsEnabled },
{ displayName: body.displayName ?? companyName, subdomain: companySlug, paymentMethodsEnabled, ...body },
))
}
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
return presentBrand(await repo.upsertBrand(
companyId,
{ logoUrl: url },
{ displayName: companyName, subdomain: companySlug, logoUrl: url },
))
}
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
return presentBrand(await repo.upsertBrand(
companyId,
{ heroImageUrl: url },
{ displayName: companyName, subdomain: companySlug, heroImageUrl: url },
))
}
export async function checkSubdomainAvailability(subdomain: string, companyId: string) {
const existing = await repo.findBrandBySubdomain(subdomain, companyId)
return { available: !existing }
}
export async function setCustomDomain(companyId: string, companyName: string, companySlug: string, customDomain: string) {
const normalized = customDomain.toLowerCase().trim()
const existing = await repo.findBrandByCustomDomain(normalized, companyId)
if (existing) throw new ConflictError('This custom domain is already in use')
return repo.upsertBrand(
companyId,
{ customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
{ displayName: companyName, subdomain: companySlug, customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
)
}
export async function getCustomDomainStatus(companyId: string) {
const brand = await repo.findBrand(companyId)
const customDomain = brand?.customDomain ?? null
return {
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',
}
}
export async function removeCustomDomain(companyId: string) {
const result = await repo.clearCustomDomain(companyId)
return { success: result.count > 0 }
}
export async function getContractSettings(companyId: string) {
return repo.findContractSettings(companyId)
}
export async function updateContractSettings(companyId: string, data: any) {
return repo.upsertContractSettings(companyId, data)
}
export async function getInsurancePolicies(companyId: string) {
return repo.findInsurancePolicies(companyId)
}
export async function createInsurancePolicy(companyId: string, data: any) {
return repo.createInsurancePolicy(companyId, data)
}
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
const result = await repo.updateInsurancePolicy(id, companyId, data)
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
return repo.findInsurancePolicyOrThrow(id)
}
export async function deleteInsurancePolicy(id: string, companyId: string) {
const result = await repo.deleteInsurancePolicy(id, companyId)
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
}
export async function getPricingRules(companyId: string) {
return repo.findPricingRules(companyId)
}
export async function createPricingRule(companyId: string, data: any) {
return repo.createPricingRule(companyId, data)
}
export async function updatePricingRule(id: string, companyId: string, data: any) {
const result = await repo.updatePricingRule(id, companyId, data)
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
return repo.findPricingRuleOrThrow(id)
}
export async function deletePricingRule(id: string, companyId: string) {
const result = await repo.deletePricingRule(id, companyId)
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
}
export async function getAccountingSettings(companyId: string) {
return repo.findAccountingSettings(companyId)
}
export async function updateAccountingSettings(companyId: string, data: any) {
return repo.upsertAccountingSettings(companyId, data)
}
export async function getApiKey(companyId: string) {
return repo.findApiKey(companyId)
}
export async function regenerateApiKey(companyId: string) {
return repo.regenerateApiKey(companyId)
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as repo from './company.repo'
import * as service from './company.service'
vi.mock('./company.repo')
vi.mock('../../lib/storage', () => ({
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'),
}))
const mockCompany = {
id: 'comp_1',
name: 'Test Rentals',
slug: 'test-rentals',
email: 'info@test.com',
brand: null,
subscription: null,
_count: { vehicles: 5, customers: 10, reservations: 20 },
}
const mockBrand = {
id: 'brand_1',
companyId: 'comp_1',
displayName: 'Test Rentals',
subdomain: 'test-rentals',
logoUrl: null,
heroImageUrl: null,
customDomain: null,
customDomainVerified: false,
amanpayMerchantId: null,
amanpaySecretKey: null,
paypalEmail: null,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('company.service', () => {
describe('getCompany', () => {
it('returns company with brand and subscription', async () => {
vi.mocked(repo.findCompany).mockResolvedValue(mockCompany as any)
const result = await service.getCompany('comp_1')
expect(result).toEqual(mockCompany)
expect(repo.findCompany).toHaveBeenCalledWith('comp_1')
})
})
describe('updateCompany', () => {
it('updates company profile', async () => {
vi.mocked(repo.updateCompany).mockResolvedValue({ ...mockCompany, name: 'Updated Rentals' } as any)
const result = await service.updateCompany('comp_1', { name: 'Updated Rentals' })
expect(repo.updateCompany).toHaveBeenCalledWith('comp_1', { name: 'Updated Rentals' })
})
})
describe('uploadLogo', () => {
it('uploads logo and upserts brand', async () => {
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
const result = await service.uploadLogo('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
expect(repo.upsertBrand).toHaveBeenCalledWith(
'comp_1',
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
)
})
})
describe('uploadHeroImage', () => {
it('uploads hero image and upserts brand', async () => {
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, heroImageUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
await service.uploadHeroImage('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
expect(repo.upsertBrand).toHaveBeenCalledWith(
'comp_1',
expect.objectContaining({ heroImageUrl: expect.any(String) }),
expect.objectContaining({ heroImageUrl: expect.any(String) }),
)
})
})
describe('checkSubdomainAvailability', () => {
it('returns available=true when subdomain is free', async () => {
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(null)
const result = await service.checkSubdomainAvailability('my-rental', 'comp_1')
expect(result.available).toBe(true)
})
it('returns available=false when subdomain is taken', async () => {
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(mockBrand as any)
const result = await service.checkSubdomainAvailability('taken-rental', 'comp_1')
expect(result.available).toBe(false)
})
})
describe('setCustomDomain', () => {
it('throws ConflictError when custom domain is already in use', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(mockBrand as any)
await expect(service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'taken.com')).rejects.toThrow('This custom domain is already in use')
})
it('sets custom domain when available', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null)
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, customDomain: 'my-rental.com' } as any)
await service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'My-Rental.com')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'comp_1',
expect.objectContaining({ customDomain: 'my-rental.com' }),
expect.objectContaining({ customDomain: 'my-rental.com' }),
)
})
})
describe('updateInsurancePolicy', () => {
it('throws NotFoundError when policy does not belong to company', async () => {
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
await expect(service.updateInsurancePolicy('pol_1', 'comp_1', { name: 'CDW' })).rejects.toThrow('Insurance policy not found')
})
})
describe('deletePricingRule', () => {
it('throws NotFoundError when rule does not belong to company', async () => {
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
await expect(service.deletePricingRule('rule_1', 'comp_1')).rejects.toThrow('Pricing rule not found')
})
})
})
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
complaint: { findMany: vi.fn(), count: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() },
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './complaint.repo'
describe('complaint.repo edge behavior', () => {
beforeEach(() => vi.clearAllMocks())
it('lists complaints with company-scoped filters and deterministic ordering', async () => {
vi.mocked(prisma.complaint.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.complaint.count).mockResolvedValue(0 as never)
await repo.findMany('company_1', { status: 'OPEN', severity: 'LEVEL_2' }, 40, 20)
const where = { companyId: 'company_1', status: 'OPEN', severity: 'LEVEL_2' }
expect(prisma.complaint.findMany).toHaveBeenCalledWith(expect.objectContaining({
where,
skip: 40,
take: 20,
orderBy: { createdAt: 'desc' },
}))
expect(prisma.complaint.count).toHaveBeenCalledWith({ where })
})
it('creates complaints with linked reservation, review and customer ids preserved', async () => {
await repo.create({
companyId: 'company_1',
reservationId: 'reservation_1',
reviewId: 'review_1',
customerId: 'customer_1',
severity: 'LEVEL_3',
category: 'DAMAGE_CLAIM',
subject: 'Damage dispute',
assignedTo: 'employee_1',
})
expect(prisma.complaint.create).toHaveBeenCalledWith(expect.objectContaining({
data: {
companyId: 'company_1',
reservationId: 'reservation_1',
reviewId: 'review_1',
customerId: 'customer_1',
severity: 'LEVEL_3',
category: 'DAMAGE_CLAIM',
subject: 'Damage dispute',
assignedTo: 'employee_1',
},
}))
})
it('updates by complaint id without accepting a company id bypass from caller data', async () => {
await repo.updateById('complaint_1', { status: 'RESOLVED', resolution: 'Refunded deposit' })
expect(prisma.complaint.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'complaint_1' },
data: { status: 'RESOLVED', resolution: 'Refunded deposit' },
}))
})
it('deletes by primary id', async () => {
await repo.deleteById('complaint_1')
expect(prisma.complaint.delete).toHaveBeenCalledWith({ where: { id: 'complaint_1' } })
})
})

Some files were not shown because too many files have changed in this diff Show More