286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
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<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('/', 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<string, string>
|
|
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
|