add first files

This commit is contained in:
root
2026-04-30 14:59:57 -04:00
parent 2b97c1a04a
commit 695a7f7cc7
92 changed files with 4873 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
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 }