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 { redis } from './lib/redis' import { prisma } from './lib/prisma' // ─── Routes ─────────────────────────────────────────────────── import webhookRouter from './routes/webhooks' 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' const app = express() const server = http.createServer(app) // ─── Socket.io ──────────────────────────────────────────────── const io = new SocketIOServer(server, { cors: { origin: '*', methods: ['GET', 'POST'] }, }) io.on('connection', (socket) => { const userId = socket.handshake.auth?.userId 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) => { const userId = channel.replace('notifications:', '') io.to(`user:${userId}`).emit('notification', JSON.parse(message)) }) // ─── Middleware ──────────────────────────────────────────────── // 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: process.env.CORS_ORIGINS?.split(',') ?? '*', credentials: true })) app.use(morgan('combined')) app.use(express.json({ limit: '10mb' })) // ─── API Routes ─────────────────────────────────────────────── const v1 = '/api/v1' app.use(`${v1}/auth/renter`, renterAuthRouter) app.use(`${v1}/vehicles`, vehiclesRouter) app.use(`${v1}/reservations`, reservationsRouter) app.use(`${v1}/team`, teamRouter) app.use(`${v1}/customers`, customersRouter) app.use(`${v1}/offers`, offersRouter) app.use(`${v1}/analytics`, analyticsRouter) app.use(`${v1}/notifications`, notificationsRouter) app.use(`${v1}/marketplace`, marketplaceRouter) app.use(`${v1}/admin`, adminRouter) // ─── Health check ───────────────────────────────────────────── app.get('/health', (_req, res) => { res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() }) }) // ─── 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' }, }) } } }) // ─── Start ──────────────────────────────────────────────────── const PORT = process.env.API_PORT ?? 4000 server.listen(PORT, () => { console.log(`[API] Server running on port ${PORT}`) }) export { app, io }