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 }
+31
View File
@@ -0,0 +1,31 @@
import { v2 as cloudinary } from 'cloudinary'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true,
})
export { cloudinary }
export async function uploadImage(
buffer: Buffer,
folder: string,
publicId?: string
): Promise<string> {
return new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{ folder, public_id: publicId, resource_type: 'image', quality: 'auto', fetch_format: 'auto' },
(error, result) => {
if (error) return reject(error)
resolve(result!.secure_url)
}
)
uploadStream.end(buffer)
})
}
export async function deleteImage(publicId: string): Promise<void> {
await cloudinary.uploader.destroy(publicId)
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from '@rentaldrivego/database'
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)
})
@@ -0,0 +1,60 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { AdminRole } from '@rentaldrivego/database'
const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: 5,
ADMIN: 4,
SUPPORT: 3,
FINANCE: 2,
VIEWER: 1,
}
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) {
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'admin') {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
}
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
if (!admin || !admin.isActive) {
return res.status(401).json({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
}
req.admin = admin
next()
} catch {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
}
}
export function requireAdminRole(minimumRole: AdminRole) {
return (req: Request, res: Response, next: NextFunction) => {
const admin = req.admin
if (!admin) {
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
}
const rank = ROLE_RANK[admin.role] ?? 0
const required = ROLE_RANK[minimumRole] ?? 99
if (rank < required) {
return res.status(403).json({
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
})
}
next()
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
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 company = await prisma.company.findUnique({ where: { apiKey } })
if (!company) {
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
}
req.company = company
req.companyId = company.id
next()
}
@@ -0,0 +1,45 @@
import { Request, Response, NextFunction } from 'express'
import { clerkClient } from '@clerk/clerk-sdk-node'
import { prisma } from '../lib/prisma'
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!sessionToken) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
try {
const session = await clerkClient.sessions.verifySession(sessionToken, sessionToken)
const clerkUserId = session.userId
const employee = await prisma.employee.findUnique({
where: { clerkUserId },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
})
}
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
next()
} catch {
return res.status(401).json({
error: 'invalid_token',
message: 'Invalid or expired session token',
statusCode: 401,
})
}
}
@@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) {
return res.status(401).json({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'renter') {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
}
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
if (!renter || !renter.isActive) {
return res.status(401).json({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
}
req.renterId = renter.id
next()
} catch {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
}
}
export async function optionalRenterAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) return next()
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type === 'renter') {
req.renterId = payload.sub
}
} catch {
// Optional — ignore invalid tokens
}
next()
}
+32
View File
@@ -0,0 +1,32 @@
import { Request, Response, NextFunction } from 'express'
import { EmployeeRole } from '@rentaldrivego/database'
const ROLE_RANK: Record<EmployeeRole, number> = {
OWNER: 3,
MANAGER: 2,
AGENT: 1,
}
export function requireRole(minimumRole: EmployeeRole) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) {
return res.status(401).json({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
}
const employeeRank = ROLE_RANK[employee.role] ?? 0
const requiredRank = ROLE_RANK[minimumRole] ?? 99
if (employeeRank < requiredRank) {
return res.status(403).json({
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
requiredRole: minimumRole,
yourRole: employee.role,
})
}
next()
}
}
@@ -0,0 +1,24 @@
import { Request, Response, NextFunction } from 'express'
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
export function requireSubscription(req: Request, res: Response, next: NextFunction) {
const company = req.company
if (!company) {
return res.status(401).json({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
}
if (BLOCKED_STATUSES.includes(company.status)) {
return res.status(402).json({
error: 'subscription_' + company.status.toLowerCase(),
message:
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.',
statusCode: 402,
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing`,
})
}
next()
}
+24
View File
@@ -0,0 +1,24 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
export async function requireTenant(req: Request, res: Response, next: NextFunction) {
if (!req.companyId) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Tenant context missing — requireCompanyAuth must run first',
statusCode: 401,
})
}
const company = await prisma.company.findUnique({ where: { id: req.companyId } })
if (!company) {
return res.status(401).json({
error: 'company_not_found',
message: 'Company not found',
statusCode: 401,
})
}
req.company = company
next()
}
+219
View File
@@ -0,0 +1,219 @@
import { Router } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { authenticator } from 'otplib'
import qrcode from 'qrcode'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
const router = Router()
function signAdminToken(adminId: string) {
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
}
// ─── Auth ─────────────────────────────────────────────────────
router.post('/auth/login', async (req, res, next) => {
try {
const { email, password, totpCode } = z.object({ email: z.string().email(), password: z.string(), totpCode: z.string().optional() }).parse(req.body)
const admin = await prisma.adminUser.findUnique({ where: { email } })
if (!admin || !admin.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
const valid = await bcrypt.compare(password, admin.passwordHash)
if (!valid) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if (admin.totpEnabled) {
if (!totpCode) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
const valid2fa = authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
if (!valid2fa) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
}
await prisma.adminUser.update({ where: { id: admin.id }, data: { lastLoginAt: new Date(), lastLoginIp: req.ip } })
const token = signAdminToken(admin.id)
await prisma.auditLog.create({ data: { adminUserId: admin.id, action: 'ADMIN_LOGIN', resource: 'AdminUser', resourceId: admin.id, ipAddress: req.ip, userAgent: req.headers['user-agent'] } })
res.json({ data: { token, admin: { id: admin.id, email: admin.email, firstName: admin.firstName, lastName: admin.lastName, role: admin.role, totpEnabled: admin.totpEnabled } } })
} catch (err) { next(err) }
})
router.get('/auth/me', requireAdminAuth, (req, res) => {
const { passwordHash, totpSecret, ...safe } = req.admin as any
res.json({ data: safe })
})
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
try {
const secret = authenticator.generateSecret()
await prisma.adminUser.update({ where: { id: req.admin.id }, data: { totpSecret: secret } })
const otpauth = authenticator.keyuri(req.admin.email, 'RentalDriveGo Admin', secret)
const qr = await qrcode.toDataURL(otpauth)
res.json({ data: { secret, qrCode: qr } })
} catch (err) { next(err) }
})
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
try {
const { code } = z.object({ code: z.string().length(6) }).parse(req.body)
const admin = await prisma.adminUser.findUniqueOrThrow({ where: { id: req.admin.id } })
if (!admin.totpSecret) return res.status(400).json({ error: 'no_totp_secret', message: '2FA setup not initiated', statusCode: 400 })
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
await prisma.adminUser.update({ where: { id: admin.id }, data: { totpEnabled: true } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
// ─── Companies ────────────────────────────────────────────────
router.get('/companies', requireAdminAuth, async (req, res, next) => {
try {
const { q, status, plan, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = {}
if (status) where.status = status
if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }, { slug: { contains: q, mode: 'insensitive' } }]
if (plan) where.subscription = { plan }
const [companies, total] = await Promise.all([
prisma.company.findMany({
where,
include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true } }, subscription: { select: { plan: true, status: true } }, _count: { select: { employees: true, vehicles: true } } },
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
orderBy: { createdAt: 'desc' },
}),
prisma.company.count({ where }),
])
res.json({ data: companies, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
} catch (err) { next(err) }
})
router.get('/companies/:id', requireAdminAuth, async (req, res, next) => {
try {
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id }, include: { brand: true, subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } }, employees: true } })
res.json({ data: company })
} catch (err) { next(err) }
})
router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { status, reason } = z.object({ status: z.enum(['ACTIVE','SUSPENDED','CANCELLED']), reason: z.string().optional() }).parse(req.body)
const before = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id } })
const updated = await prisma.company.update({ where: { id: req.params.id }, data: { status } })
await prisma.auditLog.create({ data: { adminUserId: req.admin.id, action: `SET_COMPANY_STATUS_${status}`, resource: 'Company', resourceId: req.params.id, companyId: req.params.id, before: { status: before.status }, after: { status }, note: reason, ipAddress: req.ip } })
res.json({ data: updated })
} catch (err) { next(err) }
})
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
await prisma.company.delete({ where: { id: req.params.id } })
await prisma.auditLog.create({ data: { adminUserId: req.admin.id, action: 'DELETE_COMPANY', resource: 'Company', resourceId: req.params.id, ipAddress: req.ip } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
// ─── Impersonation ────────────────────────────────────────────
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id }, include: { employees: { where: { role: 'OWNER' } } } })
const token = jwt.sign({ companyId: company.id, employeeId: company.employees[0]?.id, isImpersonation: true }, process.env.JWT_SECRET!, { expiresIn: '30m' })
await prisma.auditLog.create({ data: { adminUserId: req.admin.id, action: 'IMPERSONATE_COMPANY', resource: 'Company', resourceId: company.id, companyId: company.id, ipAddress: req.ip } })
res.json({ data: { token, expiresIn: 1800 } })
} catch (err) { next(err) }
})
// ─── Renters ──────────────────────────────────────────────────
router.get('/renters', requireAdminAuth, async (req, res, next) => {
try {
const { q, blocked, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = {}
if (blocked !== undefined) where.isActive = blocked === 'false'
if (q) where.OR = [{ firstName: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }]
const [renters, 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: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
prisma.renter.count({ where }),
])
res.json({ data: renters, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
} catch (err) { next(err) }
})
router.post('/renters/:id/block', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
await prisma.renter.update({ where: { id: req.params.id }, data: { isActive: false } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/renters/:id/unblock', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
await prisma.renter.update({ where: { id: req.params.id }, data: { isActive: true } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
// ─── Metrics ──────────────────────────────────────────────────
router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
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(),
])
res.json({ data: { totalCompanies, activeCompanies, trialingCompanies, suspendedCompanies, totalRenters, totalReservations } })
} catch (err) { next(err) }
})
// ─── Audit Log ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { adminId, action, companyId, page = '1', pageSize = '50' } = req.query as Record<string, string>
const where: any = {}
if (adminId) where.adminUserId = adminId
if (action) where.action = { contains: action }
if (companyId) where.companyId = companyId
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({ where, include: { adminUser: { select: { firstName: true, lastName: true, email: true } } }, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
prisma.auditLog.count({ where }),
])
res.json({ data: logs, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
} catch (err) { next(err) }
})
// ─── Admin Users (SUPER_ADMIN only) ───────────────────────────
router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const admins = await prisma.adminUser.findMany({ select: { id: true, email: true, firstName: true, lastName: true, role: true, isActive: true, totpEnabled: true, lastLoginAt: true, createdAt: true } })
res.json({ data: admins })
} catch (err) { next(err) }
})
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const body = z.object({ email: z.string().email(), firstName: z.string(), lastName: z.string(), role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']), password: z.string().min(8) }).parse(req.body)
const passwordHash = await bcrypt.hash(body.password, 12)
const admin = await prisma.adminUser.create({ data: { ...body, passwordHash, password: undefined } as any })
const { passwordHash: _, ...safe } = admin
res.status(201).json({ data: safe })
} catch (err) { next(err) }
})
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const { role } = z.object({ role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body)
await prisma.adminUser.update({ where: { id: req.params.id }, data: { role } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
export default router
+52
View File
@@ -0,0 +1,52 @@
import { Router } from 'express'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { generateFinancialReport, toCsv } from '../services/financialReportService'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/summary', async (req, res, next) => {
try {
const { period = '30d' } = req.query as Record<string, 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: req.companyId, createdAt: { gte: since } } }),
prisma.vehicle.count({ where: { companyId: req.companyId, status: 'AVAILABLE' } }),
prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['COMPLETED'] }, createdAt: { gte: since } }, _sum: { totalAmount: true } }),
prisma.customer.count({ where: { companyId: req.companyId } }),
])
res.json({ data: { totalReservations, activeVehicles, totalRevenue: totalRevenue._sum.totalAmount ?? 0, totalCustomers, period } })
} catch (err) { next(err) }
})
router.get('/sources', async (req, res, next) => {
try {
const sources = await prisma.reservation.groupBy({ by: ['source'], where: { companyId: req.companyId }, _count: { id: true } })
res.json({ data: sources })
} catch (err) { next(err) }
})
router.get('/report', async (req, res, next) => {
try {
const { from, to, format = 'JSON' } = req.query as Record<string, string>
if (!from || !to) return res.status(400).json({ error: 'missing_params', message: 'from and to date params required', statusCode: 400 })
const report = await generateFinancialReport(req.companyId, new Date(from), new Date(to))
if (format === 'CSV') {
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="report-${from}-${to}.csv"`)
return res.send(toCsv(report.rows))
}
res.json({ data: report })
} catch (err) { next(err) }
})
export default router
+83
View File
@@ -0,0 +1,83 @@
import { Router } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireRenterAuth } from '../middleware/requireRenterAuth'
const router = Router()
function signRenterToken(renterId: string) {
return jwt.sign({ sub: renterId, type: 'renter' }, process.env.JWT_SECRET!, {
expiresIn: process.env.RENTER_JWT_EXPIRY ?? '7d',
})
}
router.post('/signup', async (req, res, next) => {
try {
const body = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
phone: z.string().optional(),
}).parse(req.body)
const existing = await prisma.renter.findUnique({ where: { email: body.email } })
if (existing) return res.status(409).json({ error: 'email_taken', message: 'Email already in use', statusCode: 409 })
const passwordHash = await bcrypt.hash(body.password, 12)
const renter = await prisma.renter.create({
data: { firstName: body.firstName, lastName: body.lastName, email: body.email, passwordHash, phone: body.phone ?? null },
})
const token = signRenterToken(renter.id)
res.status(201).json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } })
} catch (err) { next(err) }
})
router.post('/login', async (req, res, next) => {
try {
const body = z.object({ email: z.string().email(), password: z.string() }).parse(req.body)
const renter = await prisma.renter.findUnique({ where: { email: body.email } })
if (!renter || !renter.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
const valid = await bcrypt.compare(body.password, renter.passwordHash)
if (!valid) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
const token = signRenterToken(renter.id)
res.json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } })
} catch (err) { next(err) }
})
router.get('/me', requireRenterAuth, async (req, res, next) => {
try {
const renter = await prisma.renter.findUniqueOrThrow({ where: { id: req.renterId }, select: { id: true, firstName: true, lastName: true, email: true, phone: true, preferredLocale: true, preferredCurrency: true, emailVerified: true } })
res.json({ data: renter })
} catch (err) { next(err) }
})
router.patch('/me', requireRenterAuth, async (req, res, next) => {
try {
const body = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
phone: z.string().optional(),
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
}).parse(req.body)
const renter = await prisma.renter.update({ where: { id: req.renterId }, data: body })
res.json({ data: renter })
} catch (err) { next(err) }
})
router.post('/me/fcm-token', requireRenterAuth, async (req, res, next) => {
try {
const { fcmToken } = z.object({ fcmToken: z.string() }).parse(req.body)
await prisma.renter.update({ where: { id: req.renterId }, data: { fcmToken } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
export default router
+125
View File
@@ -0,0 +1,125 @@
import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
import { validateAndFlagLicense } from '../services/licenseValidationService'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const customerSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
driverLicense: z.string().optional(),
dateOfBirth: z.string().datetime().optional(),
nationality: z.string().optional(),
address: z.record(z.unknown()).optional(),
notes: z.string().optional(),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
licenseCountry: z.string().optional(),
licenseNumber: z.string().optional(),
licenseCategory: z.string().optional(),
})
router.get('/', async (req, res, next) => {
try {
const { q, flagged, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = { companyId: req.companyId }
if (flagged !== undefined) where.flagged = flagged === 'true'
if (q) where.OR = [{ firstName: { contains: q, mode: 'insensitive' } }, { lastName: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }]
const [customers, total] = await Promise.all([
prisma.customer.findMany({ where, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
prisma.customer.count({ where }),
])
res.json({ data: customers, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
try {
const body = customerSchema.parse(req.body)
const customer = await prisma.customer.create({
data: {
...body,
companyId: req.companyId,
dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null,
licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null,
licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null,
},
})
if (body.licenseExpiry) {
await validateAndFlagLicense(customer.id).catch(() => null)
}
res.status(201).json({ data: customer })
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const customer = await prisma.customer.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: { reservations: { include: { vehicle: true }, orderBy: { createdAt: 'desc' }, take: 20 } },
})
res.json({ data: customer })
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
try {
const body = customerSchema.partial().parse(req.body)
const updated = await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : undefined, licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : undefined, licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : undefined } })
if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Customer not found', statusCode: 404 })
if (body.licenseExpiry) await validateAndFlagLicense(req.params.id).catch(() => null)
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: req.params.id } })
res.json({ data: customer })
} catch (err) { next(err) }
})
router.post('/:id/flag', async (req, res, next) => {
try {
const { reason } = z.object({ reason: z.string().optional() }).parse(req.body)
await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { flagged: true, flagReason: reason ?? null } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.delete('/:id/flag', async (req, res, next) => {
try {
await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { flagged: false, flagReason: null } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/validate-license', async (req, res, next) => {
try {
const customer = await prisma.customer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const result = await validateAndFlagLicense(customer.id)
res.json({ data: result })
} catch (err) { next(err) }
})
router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
try {
const { decision, note } = z.object({ decision: z.enum(['APPROVE', 'DENY']), note: z.string().optional() }).parse(req.body)
const customer = await prisma.customer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const updated = await prisma.customer.update({
where: { id: customer.id },
data: {
licenseValidationStatus: decision === 'APPROVE' ? 'APPROVED' : 'DENIED',
licenseApprovedBy: `${req.employee.firstName} ${req.employee.lastName}`,
licenseApprovedAt: new Date(),
licenseApprovalNote: note ?? null,
},
})
res.json({ data: updated })
} catch (err) { next(err) }
})
export default router
+101
View File
@@ -0,0 +1,101 @@
import { Router } from 'express'
import { prisma } from '../lib/prisma'
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
const router = Router()
router.use(optionalRenterAuth)
router.get('/offers', async (req, res, next) => {
try {
const offers = await prisma.offer.findMany({
where: { isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
include: { company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, primaryColor: true } } } } },
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
take: 50,
})
res.json({ data: offers })
} catch (err) { next(err) }
})
router.get('/companies', async (req, res, next) => {
try {
const { city, hasOffer, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = { status: 'ACTIVE', brand: { isListedOnMarketplace: true } }
if (city) where.brand = { ...where.brand, publicCity: { contains: city, mode: 'insensitive' } }
const companies = await prisma.company.findMany({
where,
include: {
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
_count: { select: { vehicles: { where: { isPublished: true } } } },
},
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
})
res.json({ data: companies })
} catch (err) { next(err) }
})
router.get('/search', async (req, res, next) => {
try {
const { city, startDate, endDate, category, maxPrice, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = { isPublished: true, company: { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } }
if (category) where.category = category
if (maxPrice) where.dailyRate = { lte: parseInt(maxPrice) }
const vehicles = await prisma.vehicle.findMany({
where,
include: {
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
},
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
orderBy: { dailyRate: 'asc' },
})
res.json({ data: vehicles })
} catch (err) { next(err) }
})
router.get('/:slug', async (req, res, next) => {
try {
const company = await prisma.company.findFirst({
where: { slug: req.params.slug, status: 'ACTIVE' },
include: {
brand: true,
vehicles: { where: { isPublished: true }, orderBy: { createdAt: 'desc' } },
offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } },
},
})
if (!company) return res.status(404).json({ error: 'not_found', message: 'Company not found', statusCode: 404 })
res.json({ data: company })
} catch (err) { next(err) }
})
router.get('/:slug/reviews', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug } })
const reviews = await prisma.review.findMany({
where: { companyId: company.id, isPublished: true },
include: { renter: { select: { firstName: true, lastName: true } } },
orderBy: { createdAt: 'desc' },
take: 50,
})
res.json({ data: reviews })
} catch (err) { next(err) }
})
router.post('/offers/:code/validate', async (req, res, next) => {
try {
const offer = await prisma.offer.findFirst({
where: { promoCode: req.params.code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
})
if (!offer) return res.status(404).json({ error: 'invalid_code', message: 'Promo code not found or expired', statusCode: 404 })
if (offer.maxRedemptions && offer.redemptionCount >= offer.maxRedemptions) {
return res.status(409).json({ error: 'code_exhausted', message: 'Promo code has reached its maximum redemptions', statusCode: 409 })
}
res.json({ data: offer })
} catch (err) { next(err) }
})
export default router
+77
View File
@@ -0,0 +1,77 @@
import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRenterAuth } from '../middleware/requireRenterAuth'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
const router = Router()
// ─── Company notifications ────────────────────────────────────
const companyAuth = [requireCompanyAuth, requireTenant, requireSubscription]
router.get('/company', ...companyAuth, async (req: any, res: any, next: any) => {
try {
const { unread } = req.query as Record<string, string>
const where: any = { companyId: req.companyId, channel: 'IN_APP' }
if (unread === 'true') where.readAt = null
const notifications = await prisma.notification.findMany({ where, orderBy: { createdAt: 'desc' }, take: 50 })
res.json({ data: notifications })
} catch (err) { next(err) }
})
router.post('/company/:id/read', ...companyAuth, async (req: any, res: any, next: any) => {
try {
await prisma.notification.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { readAt: new Date(), status: 'READ' } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/company/read-all', ...companyAuth, async (req: any, res: any, next: any) => {
try {
await prisma.notification.updateMany({ where: { companyId: req.companyId, readAt: null }, data: { readAt: new Date(), status: 'READ' } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.get('/company/preferences', ...companyAuth, async (req: any, res: any, next: any) => {
try {
const prefs = await prisma.notificationPreference.findMany({ where: { employeeId: req.employee.id } })
res.json({ data: prefs })
} catch (err) { next(err) }
})
router.patch('/company/preferences', ...companyAuth, async (req: any, res: any, next: any) => {
try {
const body = z.array(z.object({ notificationType: z.string(), channel: z.string(), enabled: z.boolean() })).parse(req.body)
for (const pref of body) {
await prisma.notificationPreference.upsert({
where: { employeeId_notificationType_channel: { employeeId: req.employee.id, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel } },
create: { employeeId: req.employee.id, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel, enabled: pref.enabled },
update: { enabled: pref.enabled },
})
}
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
// ─── Renter notifications ─────────────────────────────────────
router.get('/renter', requireRenterAuth, async (req: any, res: any, next: any) => {
try {
const notifications = await prisma.notification.findMany({ where: { renterId: req.renterId, channel: 'IN_APP' }, orderBy: { createdAt: 'desc' }, take: 50 })
res.json({ data: notifications })
} catch (err) { next(err) }
})
router.post('/renter/:id/read', requireRenterAuth, async (req: any, res: any, next: any) => {
try {
await prisma.notification.updateMany({ where: { id: req.params.id, renterId: req.renterId }, data: { readAt: new Date(), status: 'READ' } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
export default router
+100
View File
@@ -0,0 +1,100 @@
import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const offerSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
termsAndConds: z.string().optional(),
type: z.enum(['PERCENTAGE','FIXED_AMOUNT','FREE_DAY','SPECIAL_RATE']),
discountValue: z.number().int().min(0),
specialRate: z.number().int().optional(),
appliesToAll: z.boolean().default(true),
categories: z.array(z.enum(['ECONOMY','COMPACT','MIDSIZE','FULLSIZE','SUV','LUXURY','VAN','TRUCK'])).default([]),
minRentalDays: z.number().int().optional(),
maxRentalDays: z.number().int().optional(),
promoCode: z.string().optional(),
maxRedemptions: z.number().int().optional(),
validFrom: z.string().datetime(),
validUntil: z.string().datetime(),
isActive: z.boolean().default(true),
isPublic: z.boolean().default(true),
isFeatured: z.boolean().default(false),
vehicleIds: z.array(z.string()).default([]),
})
router.get('/', async (req, res, next) => {
try {
const { active, public: pub } = req.query as Record<string, string>
const where: any = { companyId: req.companyId }
if (active !== undefined) where.isActive = active === 'true'
if (pub !== undefined) where.isPublic = pub === 'true'
const offers = await prisma.offer.findMany({ where, orderBy: { createdAt: 'desc' } })
res.json({ data: offers })
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
try {
const { vehicleIds, ...body } = offerSchema.parse(req.body)
const offer = await prisma.offer.create({
data: { ...body, companyId: req.companyId, validFrom: new Date(body.validFrom), validUntil: new Date(body.validUntil), vehicles: vehicleIds.length > 0 ? { create: vehicleIds.map((id) => ({ vehicleId: id })) } : undefined },
include: { vehicles: true },
})
res.status(201).json({ data: offer })
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const offer = await prisma.offer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicles: true } })
res.json({ data: offer })
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
try {
const { vehicleIds, ...body } = offerSchema.partial().parse(req.body)
const offer = await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, validFrom: body.validFrom ? new Date(body.validFrom) : undefined, validUntil: body.validUntil ? new Date(body.validUntil) : undefined } })
if (offer.count === 0) return res.status(404).json({ error: 'not_found', message: 'Offer not found', statusCode: 404 })
const updated = await prisma.offer.findUniqueOrThrow({ where: { id: req.params.id }, include: { vehicles: true } })
res.json({ data: updated })
} catch (err) { next(err) }
})
router.delete('/:id', async (req, res, next) => {
try {
await prisma.offer.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/activate', async (req, res, next) => {
try {
await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isActive: true } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/deactivate', async (req, res, next) => {
try {
await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isActive: false } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.get('/:id/stats', async (req, res, next) => {
try {
const offer = await prisma.offer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const reservations = await prisma.reservation.count({ where: { offerId: offer.id } })
res.json({ data: { redemptions: offer.redemptionCount, reservations } })
} catch (err) { next(err) }
})
export default router
+215
View File
@@ -0,0 +1,215 @@
import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense } from '../services/licenseValidationService'
import { sendNotification } from '../services/notificationService'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const createSchema = z.object({
vehicleId: z.string().cuid(),
customerId: z.string().cuid(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
pickupLocation: z.string().optional(),
returnLocation: z.string().optional(),
offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(),
depositAmount: z.number().int().min(0).default(0),
notes: z.string().optional(),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
})
router.get('/', async (req, res, next) => {
try {
const { status, vehicleId, source, startDate, endDate, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = { companyId: req.companyId }
if (status) where.status = status
if (vehicleId) where.vehicleId = vehicleId
if (source) where.source = source
if (startDate) where.startDate = { gte: new Date(startDate) }
if (endDate) where.endDate = { lte: new Date(endDate) }
const [reservations, total] = await Promise.all([
prisma.reservation.findMany({
where,
include: { vehicle: true, customer: true },
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
orderBy: { createdAt: 'desc' },
}),
prisma.reservation.count({ where }),
])
res.json({ data: reservations, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
try {
const body = createSchema.parse(req.body)
// Validate vehicle belongs to this company
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: body.vehicleId, companyId: req.companyId } })
const customer = await prisma.customer.findFirstOrThrow({ where: { id: body.customerId, companyId: req.companyId } })
const start = new Date(body.startDate)
const end = new Date(body.endDate)
const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
// Check availability
const conflict = await prisma.reservation.findFirst({
where: { vehicleId: body.vehicleId, status: { in: ['CONFIRMED', 'ACTIVE'] }, startDate: { lt: end }, endDate: { gt: start } },
})
if (conflict) return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
let discountAmount = 0
let offerId: string | null = body.offerId ?? null
if (body.promoCodeUsed) {
const offer = await prisma.offer.findFirst({
where: { companyId: req.companyId, promoCode: body.promoCodeUsed, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
})
if (offer) {
offerId = offer.id
const base = vehicle.dailyRate * totalDays
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(base * offer.discountValue / 100)
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
await prisma.offer.update({ where: { id: offer.id }, data: { redemptionCount: { increment: 1 } } })
}
}
const baseAmount = vehicle.dailyRate * totalDays
const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, [], vehicle.dailyRate, totalDays)
const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount
const reservation = await prisma.reservation.create({
data: {
companyId: req.companyId,
vehicleId: body.vehicleId,
customerId: body.customerId,
startDate: start,
endDate: end,
pickupLocation: body.pickupLocation ?? null,
returnLocation: body.returnLocation ?? null,
offerId,
promoCodeUsed: body.promoCodeUsed ?? null,
source: 'DASHBOARD',
dailyRate: vehicle.dailyRate,
discountAmount,
totalDays,
totalAmount,
depositAmount: body.depositAmount,
notes: body.notes ?? null,
pricingRulesApplied: applied,
pricingRulesTotal: pricingTotal,
},
include: { vehicle: true, customer: true },
})
if (body.selectedInsurancePolicyIds.length > 0) {
await applyInsurancesToReservation(reservation.id, req.companyId, body.selectedInsurancePolicyIds, totalDays, baseAmount)
}
// Validate customer license
await validateAndFlagLicense(body.customerId).catch(() => null)
res.status(201).json({ data: reservation })
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
})
res.json({ data: reservation })
} catch (err) { next(err) }
})
router.post('/:id/confirm', async (req, res, next) => {
try {
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
if (reservation.status !== 'DRAFT') return res.status(400).json({ error: 'invalid_status', message: 'Only DRAFT reservations can be confirmed', statusCode: 400 })
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } })
// Notify
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
await sendNotification({ type: 'BOOKING_CONFIRMED', title: 'Booking Confirmed', body: `Your booking has been confirmed.`, companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'] }).catch(() => null)
res.json({ data: updated })
} catch (err) { next(err) }
})
router.post('/:id/checkin', async (req, res, next) => {
try {
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
if (reservation.status !== 'CONFIRMED') return res.status(400).json({ error: 'invalid_status', message: 'Only CONFIRMED reservations can be checked in', statusCode: 400 })
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null } })
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'RENTED' } })
res.json({ data: updated })
} catch (err) { next(err) }
})
router.post('/:id/checkout', async (req, res, next) => {
try {
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
if (reservation.status !== 'ACTIVE') return res.status(400).json({ error: 'invalid_status', message: 'Only ACTIVE reservations can be checked out', statusCode: 400 })
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null } })
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE', mileage: mileage ?? undefined } })
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
await sendNotification({ type: 'REVIEW_REQUEST', title: 'How was your rental?', body: 'Please leave a review for your recent rental.', companyId: req.companyId, email: customer.email, channels: ['EMAIL'] }).catch(() => null)
res.json({ data: updated })
} catch (err) { next(err) }
})
router.post('/:id/cancel', async (req, res, next) => {
try {
const { reason } = z.object({ reason: z.string().optional() }).parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) {
return res.status(400).json({ error: 'invalid_status', message: 'Reservation cannot be cancelled', statusCode: 400 })
}
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' } })
if (['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE' } })
}
res.json({ data: updated })
} catch (err) { next(err) }
})
router.get('/:id/billing', async (req, res, next) => {
try {
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: { vehicle: true, insurances: true, additionalDrivers: true },
})
const baseAmount = reservation.dailyRate * reservation.totalDays
const lineItems = [
{ description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}${reservation.totalDays} day(s)`, qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL' },
...reservation.insurances.map((ins) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })),
...reservation.additionalDrivers.filter((d) => d.totalCharge > 0).map((d) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })),
...(reservation.depositAmount > 0 ? [{ description: 'Security Deposit (refundable)', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT' }] : []),
]
const grandTotal = lineItems.reduce((s, i) => s + i.total, 0) - reservation.discountAmount
res.json({ data: { lineItems, discountAmount: reservation.discountAmount, pricingRulesApplied: reservation.pricingRulesApplied, pricingRulesTotal: reservation.pricingRulesTotal, grandTotal } })
} catch (err) { next(err) }
})
export default router
+56
View File
@@ -0,0 +1,56 @@
import { Router } from 'express'
import { z } from 'zod'
import { listEmployees, inviteEmployee, updateEmployeeRole, deactivateEmployee, reactivateEmployee, removeEmployee } from '../services/teamService'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const inviteSchema = z.object({
firstName: z.string().min(1).max(64),
lastName: z.string().min(1).max(64),
email: z.string().email(),
role: z.enum(['MANAGER', 'AGENT']),
})
router.get('/', async (req, res, next) => {
try { res.json({ data: await listEmployees(req.companyId) }) } catch (err) { next(err) }
})
router.get('/stats', async (req, res, next) => {
try {
const members = await listEmployees(req.companyId)
res.json({ data: { total: members.length, active: members.filter((m) => m.isActive && m.invitationStatus === 'accepted').length, pending: members.filter((m) => m.invitationStatus === 'pending').length, inactive: members.filter((m) => !m.isActive && m.invitationStatus === 'accepted').length } })
} catch (err) { next(err) }
})
router.post('/invite', requireRole('OWNER'), async (req, res, next) => {
try {
const body = inviteSchema.parse(req.body)
res.status(201).json({ data: await inviteEmployee(req.companyId, req.employee.id, body) })
} catch (err) { next(err) }
})
router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => {
try {
const { role } = z.object({ role: z.enum(['MANAGER', 'AGENT']) }).parse(req.body)
res.json({ data: await updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, req.params.id, { role }) })
} catch (err) { next(err) }
})
router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => {
try { res.json({ data: await deactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
})
router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => {
try { res.json({ data: await reactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
})
router.delete('/:id', requireRole('OWNER'), async (req, res, next) => {
try { res.json({ data: await removeEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
})
export default router
+156
View File
@@ -0,0 +1,156 @@
import { Router } from 'express'
import { z } from 'zod'
import multer from 'multer'
import { prisma } from '../lib/prisma'
import { uploadImage } from '../lib/cloudinary'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const vehicleSchema = z.object({
make: z.string().min(1),
model: z.string().min(1),
year: z.number().int().min(1990).max(new Date().getFullYear() + 1),
color: z.string().min(1),
licensePlate: z.string().min(1),
vin: z.string().optional(),
category: z.enum(['ECONOMY','COMPACT','MIDSIZE','FULLSIZE','SUV','LUXURY','VAN','TRUCK']),
seats: z.number().int().min(1).max(20).default(5),
transmission: z.enum(['AUTOMATIC','MANUAL']).default('AUTOMATIC'),
fuelType: z.enum(['GASOLINE','DIESEL','ELECTRIC','HYBRID']).default('GASOLINE'),
features: z.array(z.string()).default([]),
dailyRate: z.number().int().min(0),
mileage: z.number().int().optional(),
notes: z.string().optional(),
isPublished: z.boolean().default(true),
})
router.get('/', async (req, res, next) => {
try {
const { status, category, published, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = { companyId: req.companyId }
if (status) where.status = status
if (category) where.category = category
if (published !== undefined) where.isPublished = published === 'true'
const [vehicles, total] = await Promise.all([
prisma.vehicle.findMany({ where, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
prisma.vehicle.count({ where }),
])
res.json({ data: vehicles, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
try {
const body = vehicleSchema.parse(req.body)
const vehicle = await prisma.vehicle.create({ data: { ...body, companyId: req.companyId } })
res.status(201).json({ data: vehicle })
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
res.json({ data: vehicle })
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
try {
const body = vehicleSchema.partial().parse(req.body)
const vehicle = await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: body })
if (vehicle.count === 0) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
const updated = await prisma.vehicle.findUniqueOrThrow({ where: { id: req.params.id } })
res.json({ data: updated })
} catch (err) { next(err) }
})
router.delete('/:id', async (req, res, next) => {
try {
await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { status: 'OUT_OF_SERVICE', isPublished: false } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/photos', upload.array('photos', 10), async (req, res, next) => {
try {
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const files = req.files as Express.Multer.File[]
const urls = await Promise.all(files.map((f) => uploadImage(f.buffer, `vehicles/${req.companyId}`)))
const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos: [...vehicle.photos, ...urls] } })
res.json({ data: updated })
} catch (err) { next(err) }
})
router.delete('/:id/photos/:idx', async (req, res, next) => {
try {
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const idx = parseInt(req.params.idx)
const photos = vehicle.photos.filter((_, i) => i !== idx)
const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos } })
res.json({ data: updated })
} catch (err) { next(err) }
})
router.patch('/:id/publish', async (req, res, next) => {
try {
const { isPublished } = z.object({ isPublished: z.boolean() }).parse(req.body)
await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isPublished } })
res.json({ data: { success: true, isPublished } })
} catch (err) { next(err) }
})
router.get('/:id/availability', async (req, res, next) => {
try {
const { startDate, endDate } = req.query as Record<string, string>
if (!startDate || !endDate) return res.status(400).json({ error: 'missing_params', message: 'startDate and endDate required', statusCode: 400 })
const conflicts = await prisma.reservation.findMany({
where: {
vehicleId: req.params.id,
companyId: req.companyId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: new Date(endDate) },
endDate: { gt: new Date(startDate) },
},
select: { id: true, startDate: true, endDate: true, status: true },
})
res.json({ data: { available: conflicts.length === 0, conflicts } })
} catch (err) { next(err) }
})
router.get('/:id/maintenance', async (req, res, next) => {
try {
const logs = await prisma.maintenanceLog.findMany({ where: { vehicleId: req.params.id }, orderBy: { performedAt: 'desc' } })
res.json({ data: logs })
} catch (err) { next(err) }
})
router.post('/:id/maintenance', async (req, res, next) => {
try {
const body = z.object({
type: z.string().min(1),
description: z.string().optional(),
cost: z.number().int().optional(),
mileage: z.number().int().optional(),
performedAt: z.string().datetime(),
nextDueAt: z.string().datetime().optional(),
nextDueMileage: z.number().int().optional(),
}).parse(req.body)
// Validate vehicle belongs to company
await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const log = await prisma.maintenanceLog.create({ data: { ...body, vehicleId: req.params.id, performedAt: new Date(body.performedAt), nextDueAt: body.nextDueAt ? new Date(body.nextDueAt) : undefined } })
res.status(201).json({ data: log })
} catch (err) { next(err) }
})
export default router
+39
View File
@@ -0,0 +1,39 @@
import { Router } from 'express'
import { Webhook } from 'svix'
import { EmployeeRole } from '@rentaldrivego/database'
import { handleInvitationAccepted } from '../services/teamService'
const router = Router()
router.post('/clerk', async (req, res) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET
if (!webhookSecret) return res.status(500).json({ error: 'Webhook secret not configured' })
const wh = new Webhook(webhookSecret)
let event: any
try {
event = wh.verify(req.body, {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
})
} catch {
return res.status(400).json({ error: 'Invalid webhook signature' })
}
if (event.type === 'user.created') {
const clerkUserId: string = event.data.id
const email: string = event.data.email_addresses?.[0]?.email_address ?? ''
const meta = event.data.public_metadata ?? {}
const { companyId, role } = meta as { companyId?: string; role?: EmployeeRole }
if (companyId && role && email) {
await handleInvitationAccepted(clerkUserId, email, companyId, role).catch(console.error)
}
}
res.json({ received: true })
})
export default router
@@ -0,0 +1,66 @@
import { prisma } from '../lib/prisma'
export async function generateFinancialReport(companyId: string, startDate: Date, endDate: Date) {
const reservations = await prisma.reservation.findMany({
where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, startDate: { gte: startDate }, endDate: { lte: endDate } },
include: {
vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
customer: { select: { firstName: true, lastName: true, email: true } },
rentalPayments: { where: { status: 'SUCCEEDED' } },
insurances: true,
additionalDrivers: true,
},
orderBy: { startDate: 'asc' },
})
const summary = {
totalReservations: reservations.length,
totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0,
}
const rows = reservations.map((r) => ({
reservationId: r.id,
contractNumber: r.contractNumber ?? '—',
invoiceNumber: r.invoiceNumber ?? '—',
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`,
plate: r.vehicle.licensePlate,
startDate: r.startDate.toISOString().split('T')[0],
endDate: r.endDate.toISOString().split('T')[0],
days: r.totalDays,
dailyRate: r.dailyRate,
baseAmount: r.dailyRate * r.totalDays,
discount: r.discountAmount,
insurance: r.insuranceTotal,
additionalDriver: r.additionalDriverTotal,
pricingAdj: r.pricingRulesTotal,
deposit: r.depositAmount,
totalAmount: r.totalAmount,
paymentStatus: r.rentalPayments[0]?.status ?? 'UNPAID',
paymentMethod: r.rentalPayments[0]?.paymentMethod ?? '—',
source: r.source,
}))
return { summary, rows, period: { startDate, endDate } }
}
export function toCsv(rows: Record<string, any>[]): string {
if (rows.length === 0) return ''
const headers = Object.keys(rows[0]!)
return [
headers.join(','),
...rows.map((row) =>
headers.map((h) => {
const val = row[h] ?? ''
return typeof val === 'string' && val.includes(',') ? `"${val}"` : String(val)
}).join(',')
),
].join('\n')
}
+47
View File
@@ -0,0 +1,47 @@
import { InsurancePolicy } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
export function calculateInsuranceCharge(
policy: InsurancePolicy,
totalDays: number,
baseRentalAmount: number
): number {
switch (policy.chargeType) {
case 'PER_DAY': return policy.chargeValue * totalDays
case 'PER_RENTAL': return policy.chargeValue
case 'PERCENTAGE_OF_RENTAL': return Math.round(baseRentalAmount * policy.chargeValue / 100)
default: return 0
}
}
export async function applyInsurancesToReservation(
reservationId: string,
companyId: string,
selectedPolicyIds: string[],
totalDays: number,
baseRentalAmount: number
) {
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
const required = allPolicies.filter((p) => p.isRequired)
const selected = allPolicies.filter((p) => selectedPolicyIds.includes(p.id) && !p.isRequired)
const toApply = [...required, ...selected]
const records = toApply.map((policy) => ({
reservationId,
insurancePolicyId: policy.id,
policyName: policy.name,
policyType: policy.type,
chargeType: policy.chargeType,
chargeValue: policy.chargeValue,
totalCharge: calculateInsuranceCharge(policy, totalDays, baseRentalAmount),
}))
const insuranceTotal = records.reduce((s, r) => s + r.totalCharge, 0)
await prisma.$transaction([
prisma.reservationInsurance.createMany({ data: records }),
prisma.reservation.update({ where: { id: reservationId }, data: { insuranceTotal } }),
])
return { records, insuranceTotal }
}
@@ -0,0 +1,50 @@
import { prisma } from '../lib/prisma'
import { LicenseStatus } from '@rentaldrivego/database'
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000
export interface LicenseValidationResult {
status: 'VALID' | 'EXPIRING' | 'EXPIRED'
daysUntilExpiry: number | null
requiresApproval: boolean
message: string
}
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
if (!licenseExpiry) {
return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' }
}
const now = new Date()
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (licenseExpiry <= now) {
return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` }
}
if (licenseExpiry.getTime() - now.getTime() < THREE_MONTHS_MS) {
return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` }
}
return { status: 'VALID', daysUntilExpiry, requiresApproval: false, message: `Valid — expires in ${daysUntilExpiry} day(s)` }
}
export async function validateAndFlagLicense(customerId: string) {
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
const result = validateLicense(customer.licenseExpiry)
const status: LicenseStatus =
result.status === 'EXPIRED' ? 'EXPIRED' :
result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID'
await prisma.customer.update({
where: { id: customerId },
data: {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
},
})
return result
}
@@ -0,0 +1,135 @@
import { Resend } from 'resend'
import twilio from 'twilio'
import admin from 'firebase-admin'
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
const resend = new Resend(process.env.RESEND_API_KEY)
const twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
)
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
})
}
interface SendNotificationOptions {
type: NotificationType
title: string
body: string
data?: Record<string, unknown>
companyId?: string
employeeId?: string
renterId?: string
email?: string
phone?: string
fcmToken?: string
channels: NotificationChannel[]
locale?: string
}
export async function sendNotification(opts: SendNotificationOptions) {
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
for (const channel of opts.channels) {
try {
const notification = await prisma.notification.create({
data: {
type: opts.type,
title: opts.title,
body: opts.body,
data: opts.data ?? {},
channel,
status: 'PENDING',
companyId: opts.companyId ?? null,
employeeId: opts.employeeId ?? null,
renterId: opts.renterId ?? null,
},
})
let providerMessageId: string | null = null
let success = false
if (channel === 'EMAIL' && opts.email) {
const { data, error } = await resend.emails.send({
from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM}>`,
to: opts.email,
subject: opts.title,
html: `<p>${opts.body}</p>`,
})
if (!error) {
providerMessageId = data?.id ?? null
success = true
}
}
if (channel === 'SMS' && opts.phone) {
const msg = await twilioClient.messages.create({
body: opts.body,
from: process.env.TWILIO_PHONE_NUMBER!,
to: opts.phone,
})
providerMessageId = msg.sid
success = true
}
if (channel === 'WHATSAPP' && opts.phone) {
const msg = await twilioClient.messages.create({
body: opts.body,
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
to: `whatsapp:${opts.phone}`,
})
providerMessageId = msg.sid
success = true
}
if (channel === 'PUSH' && opts.fcmToken) {
const response = await admin.messaging().send({
token: opts.fcmToken,
notification: { title: opts.title, body: opts.body },
data: Object.fromEntries(
Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)])
),
})
providerMessageId = response
success = true
}
if (channel === 'IN_APP') {
// Emit via Socket.io through Redis pub/sub
const targetId = opts.employeeId ?? opts.renterId
if (targetId) {
await redis.publish(
`notifications:${targetId}`,
JSON.stringify({ ...notification, status: 'DELIVERED' })
)
}
success = true
}
await prisma.notification.update({
where: { id: notification.id },
data: {
status: success ? 'SENT' : 'FAILED',
sentAt: success ? new Date() : null,
providerMessageId,
},
})
results.push({ channel, success })
} catch (err: any) {
results.push({ channel, success: false, error: err.message })
}
}
return results
}
@@ -0,0 +1,56 @@
import { prisma } from '../lib/prisma'
interface DriverInfo {
dateOfBirth?: Date | null
licenseIssuedAt?: Date | null
}
export async function applyPricingRules(
companyId: string,
customerId: string,
additionalDrivers: DriverInfo[],
dailyRate: number,
totalDays: number
): Promise<{ applied: any[]; total: number }> {
const rules = await prisma.pricingRule.findMany({ where: { companyId, isActive: true } })
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
const allDrivers: DriverInfo[] = [customer, ...additionalDrivers]
const applied: any[] = []
const baseAmount = dailyRate * totalDays
for (const driver of allDrivers) {
for (const rule of rules) {
const driverAge = driver.dateOfBirth
? Math.floor((Date.now() - driver.dateOfBirth.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null
const licenseYears = driver.licenseIssuedAt
? Math.floor((Date.now() - driver.licenseIssuedAt.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null
let conditionMet = false
if (rule.condition === 'AGE_LESS_THAN' && driverAge !== null) conditionMet = driverAge < rule.conditionValue
else if (rule.condition === 'AGE_GREATER_THAN' && driverAge !== null) conditionMet = driverAge > rule.conditionValue
else if (rule.condition === 'LICENSE_YEARS_LESS_THAN' && licenseYears !== null) conditionMet = licenseYears < rule.conditionValue
else if (rule.condition === 'LICENSE_YEARS_GREATER_THAN' && licenseYears !== null) conditionMet = licenseYears > rule.conditionValue
if (!conditionMet) continue
let amount = 0
if (rule.adjustmentType === 'PERCENTAGE') amount = Math.round(baseAmount * rule.adjustmentValue / 100)
else if (rule.adjustmentType === 'FLAT_PER_DAY') amount = rule.adjustmentValue * totalDays
else amount = rule.adjustmentValue
if (rule.type === 'DISCOUNT') amount = -amount
applied.push({ ruleId: rule.id, name: rule.name, type: rule.type, amount })
}
}
// Deduplicate: each rule applied once even if multiple drivers qualify
const deduped = applied.reduce((acc: any[], item) => {
if (!acc.find((a) => a.ruleId === item.ruleId)) acc.push(item)
return acc
}, [])
const total = deduped.reduce((s: number, r: any) => s + r.amount, 0)
return { applied: deduped, total }
}
+143
View File
@@ -0,0 +1,143 @@
import { EmployeeRole } from '@rentaldrivego/database'
import { clerkClient } from '@clerk/clerk-sdk-node'
import { prisma } from '../lib/prisma'
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: EmployeeRole
}
export interface TeamMember {
id: string
clerkUserId: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: Date
updatedAt: Date
lastActiveAt?: Date | null
invitationStatus?: 'accepted' | 'pending' | 'revoked'
}
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
const hydrated = await Promise.allSettled(
employees.map(async (emp) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return { ...emp, lastActiveAt: clerkUser.lastActiveAt ? new Date(clerkUser.lastActiveAt) : null, invitationStatus: 'accepted' as const }
} catch {
return { ...emp, lastActiveAt: null, invitationStatus: 'pending' as const }
}
})
)
return hydrated.map((r) => (r.status === 'fulfilled' ? r.value : (r as any).value))
}
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
if (payload.role === 'OWNER') {
throw Object.assign(new Error('Cannot invite a member with the OWNER role'), { statusCode: 400, code: 'invalid_role' })
}
const existing = await prisma.employee.findFirst({ where: { companyId, email: payload.email } })
if (existing) {
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
}
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { brand: { select: { subdomain: true, displayName: true } } },
})
let clerkInvitation: any
try {
clerkInvitation = await clerkClient.invitations.createInvitation({
emailAddress: payload.email,
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
publicMetadata: { companyId, companyName: company.brand?.displayName ?? company.name, role: payload.role, invitedBy: inviterId },
})
} catch (err: any) {
if (err?.errors?.[0]?.code === 'duplicate_record') {
throw Object.assign(new Error('This email already has a pending invitation'), { statusCode: 409, code: 'duplicate_invitation' })
}
throw err
}
const employee = await prisma.employee.create({
data: { companyId, clerkUserId: `pending_${clerkInvitation.id}`, firstName: payload.firstName, lastName: payload.lastName, email: payload.email, role: payload.role, isActive: false },
})
return { employee, invitationId: clerkInvitation.id }
}
export async function updateEmployeeRole(companyId: string, requesterId: string, requesterRole: EmployeeRole, employeeId: string, payload: { role: EmployeeRole }) {
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can change team member roles'), { statusCode: 403, code: 'forbidden' })
if (target.role === 'OWNER') throw Object.assign(new Error("Cannot change the role of the account owner"), { statusCode: 400 })
if (payload.role === 'OWNER') throw Object.assign(new Error("Cannot assign the OWNER role via this endpoint"), { statusCode: 400 })
if (target.id === requesterId) throw Object.assign(new Error("You cannot change your own role"), { statusCode: 400 })
return prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
}
export async function deactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can deactivate team members'), { statusCode: 403 })
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 })
if (!target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ }
}
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
}
export async function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can reactivate team members'), { statusCode: 403 })
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (!target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.users.unbanUser(target.clerkUserId) } catch { /* non-fatal */ }
}
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
}
export async function removeEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can remove team members'), { statusCode: 403 })
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot remove the account owner'), { statusCode: 400 })
if (target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.invitations.revokeInvitation(target.clerkUserId.replace('pending_', '')) } catch { /* expired */ }
} else {
try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ }
}
await prisma.employee.delete({ where: { id: employeeId } })
return { success: true }
}
export async function handleInvitationAccepted(clerkUserId: string, email: string, companyId: string, role: EmployeeRole) {
const pendingEmployee = await prisma.employee.findFirst({
where: { companyId, email, clerkUserId: { startsWith: 'pending_' } },
})
if (!pendingEmployee) return null
return prisma.employee.update({
where: { id: pendingEmployee.id },
data: { clerkUserId, isActive: true, role },
})
}
+13
View File
@@ -0,0 +1,13 @@
import { Employee, Company, AdminUser } from '@rentaldrivego/database'
declare global {
namespace Express {
interface Request {
companyId: string
company: Company
employee: Employee
admin: AdminUser
renterId: string
}
}
}