add first files
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user