396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
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) => `
|
|
<tr>
|
|
<td>${route.method}</td>
|
|
<td><code>${route.path}</code></td>
|
|
<td>${route.description}</td>
|
|
</tr>`,
|
|
)
|
|
.join('')
|
|
|
|
res.type('html').send(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>RentalDriveGo API Docs</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 40px; color: #0f172a; background: #f8fafc; }
|
|
h1 { margin-bottom: 8px; }
|
|
p { color: #475569; }
|
|
.meta { margin-bottom: 24px; }
|
|
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; }
|
|
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
|
|
th { background: #e2e8f0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
code { background: #f1f5f9; padding: 2px 6px; border-radius: 6px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>RentalDriveGo API</h1>
|
|
<p class="meta">Base URL: <code>${v1}</code> · JSON index: <code>${v1}/docs</code></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Method</th>
|
|
<th>Path</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</body>
|
|
</html>`)
|
|
})
|
|
|
|
// ─── 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<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 = process.env.API_PORT ?? 4000
|
|
server.listen(PORT, () => {
|
|
console.log(`[API] Server running on port ${PORT}`)
|
|
})
|
|
|
|
export { app, io }
|