import { Router } from 'express' import { z } from 'zod' import multer from 'multer' import { prisma } from '../lib/prisma' import { uploadImage } from '../lib/storage' import { requireCompanyAuth } from '../middleware/requireCompanyAuth' import { requireTenant } from '../middleware/requireTenant' import { requireSubscription } from '../middleware/requireSubscription' import { requireRole } from '../middleware/requireRole' 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().default(''), 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), status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']).optional(), }) router.get('/', async (req, res, next) => { try { const { status, category, published, page = '1', pageSize = '20' } = req.query as Record 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('/', requireRole('MANAGER'), 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', requireRole('MANAGER'), async (req, res, next) => { try { const body = vehicleSchema.partial().parse(req.body) if (body.status === 'MAINTENANCE' || body.status === 'OUT_OF_SERVICE') { body.isPublished = false } else if (body.status === 'AVAILABLE' || body.status === 'RENTED') { body.isPublished = true } const existing = await prisma.vehicle.findFirst({ where: { id: req.params.id, companyId: req.companyId } }) if (!existing) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 }) const updated = await prisma.vehicle.update({ where: { id: req.params.id }, data: body }) res.json({ data: updated }) } catch (err) { next(err) } }) router.delete('/:id', requireRole('MANAGER'), 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', requireRole('MANAGER'), 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, `companies/${req.companyId}/vehicles`))) 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', requireRole('MANAGER'), async (req, res, next) => { try { const { id, idx: idxParam } = z.object({ id: z.string(), idx: z.string() }).parse(req.params) const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id, companyId: req.companyId } }) const idx = parseInt(idxParam, 10) const photos = vehicle.photos.filter((_: string, i: number) => 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', requireRole('MANAGER'), 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 if (!startDate || !endDate) return res.status(400).json({ error: 'missing_params', message: 'startDate and endDate required', statusCode: 400 }) const start = new Date(startDate) const end = new Date(endDate) const [reservationConflicts, blockConflicts] = await Promise.all([ prisma.reservation.findMany({ where: { vehicleId: req.params.id, companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE'] }, startDate: { lt: end }, endDate: { gt: start }, }, select: { id: true, startDate: true, endDate: true, status: true }, }), prisma.vehicleCalendarBlock.findMany({ where: { vehicleId: req.params.id, startDate: { lt: end }, endDate: { gt: start }, }, select: { id: true, startDate: true, endDate: true, type: true, reason: true }, }), ]) res.json({ data: { available: reservationConflicts.length === 0 && blockConflicts.length === 0, conflicts: reservationConflicts, blocks: blockConflicts, }, }) } catch (err) { next(err) } }) router.get('/:id/calendar', async (req, res, next) => { try { const year = parseInt(req.query.year as string) const month = parseInt(req.query.month as string) if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { return res.status(400).json({ error: 'invalid_params', message: 'year and month (1-12) required', statusCode: 400 }) } // Validate vehicle belongs to company await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) const rangeStart = new Date(year, month - 1, 1) const rangeEnd = new Date(year, month, 1) const [reservations, blocks] = await Promise.all([ prisma.reservation.findMany({ where: { vehicleId: req.params.id, companyId: req.companyId, status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] }, startDate: { lt: rangeEnd }, endDate: { gt: rangeStart }, }, select: { id: true, startDate: true, endDate: true, status: true, customer: { select: { firstName: true, lastName: true } }, }, orderBy: { startDate: 'asc' }, }), prisma.vehicleCalendarBlock.findMany({ where: { vehicleId: req.params.id, startDate: { lt: rangeEnd }, endDate: { gt: rangeStart }, }, orderBy: { startDate: 'asc' }, }), ]) const events = [ ...reservations.map((r) => ({ id: r.id, type: 'RESERVATION' as const, startDate: r.startDate, endDate: r.endDate, status: r.status, label: r.customer ? `${r.customer.firstName} ${r.customer.lastName}` : 'Reserved', })), ...blocks.map((b) => ({ id: b.id, type: b.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const, startDate: b.startDate, endDate: b.endDate, status: null, label: b.reason ?? (b.type === 'MAINTENANCE' ? 'Maintenance' : 'Blocked'), })), ] res.json({ data: events }) } catch (err) { next(err) } }) router.post('/:id/calendar/blocks', requireRole('MANAGER'), async (req, res, next) => { try { const { id } = z.object({ id: z.string() }).parse(req.params) const body = z.object({ startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)), endDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)), reason: z.string().optional(), type: z.enum(['MANUAL', 'MAINTENANCE']).default('MANUAL'), }).parse(req.body) const start = new Date(body.startDate) const end = new Date(body.endDate) if (end <= start) return res.status(400).json({ error: 'invalid_range', message: 'endDate must be after startDate', statusCode: 400 }) await prisma.vehicle.findFirstOrThrow({ where: { id, companyId: req.companyId } }) const block = await prisma.vehicleCalendarBlock.create({ data: { vehicleId: id, startDate: start, endDate: end, reason: body.reason, type: body.type, }, }) res.status(201).json({ data: block }) } catch (err) { next(err) } }) router.delete('/:id/calendar/blocks/:blockId', requireRole('MANAGER'), async (req, res, next) => { try { await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) await prisma.vehicleCalendarBlock.deleteMany({ where: { id: req.params.blockId, vehicleId: req.params.id }, }) res.json({ data: { success: true } }) } 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', requireRole('MANAGER'), async (req, res, next) => { try { const { id } = z.object({ id: z.string() }).parse(req.params) 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, companyId: req.companyId } }) const log = await prisma.maintenanceLog.create({ data: { ...body, vehicleId: 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