import express from 'express' import cors from 'cors' import helmet from 'helmet' import morgan from 'morgan' import http from 'http' import { Server as SocketIOServer } from 'socket.io' import cron from 'node-cron' import jwt from 'jsonwebtoken' import { z } from 'zod' import { redis } from './lib/redis' import { prisma } from './lib/prisma' import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter' // ─── Routes ─────────────────────────────────────────────────── import webhookRouter from './routes/webhooks' import companyAuthRouter from './routes/auth.company' import employeeAuthRouter from './routes/auth.employee' import renterAuthRouter from './routes/auth.renter' import vehiclesRouter from './routes/vehicles' import reservationsRouter from './routes/reservations' import teamRouter from './routes/team' import customersRouter from './routes/customers' import offersRouter from './routes/offers' import analyticsRouter from './routes/analytics' import notificationsRouter from './routes/notifications' import marketplaceRouter from './routes/marketplace' import adminRouter from './routes/admin' import companiesRouter from './routes/companies' import subscriptionsRouter from './routes/subscriptions' import siteRouter from './routes/site' import paymentsRouter from './routes/payments' const app = express() const server = http.createServer(app) // Trust the first hop from a reverse proxy so req.ip and rate-limiting // use the real client IP (from X-Forwarded-For) rather than the proxy's IP. if (process.env.NODE_ENV === 'production') { app.set('trust proxy', 1) } const v1 = '/api/v1' const defaultCorsOrigins = [ 'http://localhost:3000', 'http://localhost:3001', 'http://localhost:3002', 'http://localhost:3003', 'http://127.0.0.1:3000', 'http://127.0.0.1:3001', 'http://127.0.0.1:3002', 'http://127.0.0.1:3003', ] const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean) : defaultCorsOrigins 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: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' }, ] // ─── Socket.io ──────────────────────────────────────────────── const io = new SocketIOServer(server, { cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] }, }) const redisMessageSchema = z.object({ type: z.string(), payload: z.unknown(), }) // Authenticate socket connections via JWT before joining user rooms io.use((socket, next) => { const token = socket.handshake.auth?.token as string | undefined if (!token) return next() // unauthenticated connections allowed; they just don't join rooms try { const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string } ;(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 validated = redisMessageSchema.parse(parsed) const userId = channel.replace('notifications:', '') io.to(`user:${userId}`).emit('notification', validated) } catch (err) { console.error('[Redis] Invalid notification message:', err) } }) // ─── Middleware ──────────────────────────────────────────────── // Serve local file storage app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage'))) // Webhook must use raw body BEFORE express.json() app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter) app.use(helmet()) app.use(cors({ origin: corsOrigins, credentials: true })) app.use(morgan('combined')) app.use(express.json({ limit: '10mb' })) // ─── API Routes ─────────────────────────────────────────────── // Auth routes: strict brute-force protection app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter) app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter) app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter) // Admin routes: dedicated limit app.use(`${v1}/admin`, adminLimiter, adminRouter) // Public unauthenticated routes app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter) app.use(`${v1}/site`, publicLimiter, siteRouter) // Authenticated company/renter routes 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) // ─── Health check ───────────────────────────────────────────── 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, docsUrl: '/docs', routes: routeDocs, }) }) app.get('/docs', (_req, res) => { const rows = routeDocs .map( (route) => ` ${route.method} ${route.path} ${route.description} `, ) .join('') res.type('html').send(` RentalDriveGo API Docs

RentalDriveGo API

Base URL: ${v1} · JSON index: ${v1}/docs

${rows}
Method Path Description
`) }) // ─── Error handler ──────────────────────────────────────────── app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { const statusCode = err.statusCode ?? 500 const message = err.message ?? 'Internal server error' const code = err.code ?? 'internal_error' if (statusCode >= 500) { console.error('[API Error]', err) } // Zod validation error if (err.name === 'ZodError') { return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 }) } // Prisma not found if (err.code === 'P2025') { return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 }) } // Prisma unique constraint if (err.code === 'P2002') { return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }) } res.status(statusCode).json({ error: code, message, statusCode }) }) // ─── 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' } }) } } }) // 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 prisma.notification.create({ data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 14-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' }, }) } } }) // 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() 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 = process.env.API_PORT ?? 4000 server.listen(PORT, () => { console.log(`[API] Server running on port ${PORT}`) }) export { app, io }