refractor code,

This commit is contained in:
root
2026-05-21 12:35:49 -04:00
parent e74681e810
commit f009ca10c6
158 changed files with 215801 additions and 5884 deletions
@@ -0,0 +1,7 @@
export function presentVehicle(vehicle: any) {
return vehicle
}
export function presentVehicleList(vehicles: any[], meta: { total: number; page: number; pageSize: number; totalPages: number }) {
return { data: vehicles, ...meta }
}
@@ -0,0 +1,91 @@
import { prisma } from '../../lib/prisma'
export async function findMany(where: any, skip: number, take: number) {
return Promise.all([
prisma.vehicle.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }),
prisma.vehicle.count({ where }),
])
}
export async function findById(id: string, companyId: string) {
return prisma.vehicle.findFirst({ where: { id, companyId } })
}
export async function findFirst(id: string, companyId: string) {
return prisma.vehicle.findFirst({ where: { id, companyId } })
}
export async function create(data: any) {
return prisma.vehicle.create({ data })
}
export async function updateById(id: string, data: any) {
return prisma.vehicle.update({ where: { id }, data })
}
export async function softDelete(id: string, companyId: string) {
return prisma.vehicle.updateMany({ where: { id, companyId }, data: { status: 'OUT_OF_SERVICE', isPublished: false } })
}
export async function setPublished(id: string, companyId: string, isPublished: boolean) {
return prisma.vehicle.updateMany({ where: { id, companyId }, data: { isPublished } })
}
export async function findReservationConflicts(vehicleId: string, companyId: string, start: Date, end: Date) {
return prisma.reservation.findMany({
where: {
vehicleId,
companyId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: end },
endDate: { gt: start },
},
select: { id: true, startDate: true, endDate: true, status: true },
})
}
export async function findCalendarBlockConflicts(vehicleId: string, start: Date, end: Date) {
return prisma.vehicleCalendarBlock.findMany({
where: { vehicleId, startDate: { lt: end }, endDate: { gt: start } },
select: { id: true, startDate: true, endDate: true, type: true, reason: true },
})
}
export async function findCalendarEvents(vehicleId: string, companyId: string, rangeStart: Date, rangeEnd: Date) {
return Promise.all([
prisma.reservation.findMany({
where: {
vehicleId,
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, startDate: { lt: rangeEnd }, endDate: { gt: rangeStart } },
orderBy: { startDate: 'asc' },
}),
])
}
export async function createCalendarBlock(data: any) {
return prisma.vehicleCalendarBlock.create({ data })
}
export async function deleteCalendarBlock(id: string, vehicleId: string) {
return prisma.vehicleCalendarBlock.deleteMany({ where: { id, vehicleId } })
}
export async function findMaintenanceLogs(vehicleId: string) {
return prisma.maintenanceLog.findMany({ where: { vehicleId }, orderBy: { performedAt: 'desc' } })
}
export async function createMaintenanceLog(data: any) {
return prisma.maintenanceLog.create({ data })
}
@@ -0,0 +1,140 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { imageUpload, assertImageFiles } from '../../http/upload'
import * as service from './vehicle.service'
import {
vehicleSchema, listQuerySchema, availabilityQuerySchema, calendarQuerySchema,
calendarBlockSchema, maintenanceLogSchema, publishSchema,
idParamSchema, photoIdxSchema, blockIdParamSchema,
} from './vehicle.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
const result = await service.listVehicles(req.companyId, query)
res.json(result)
} catch (err) { next(err) }
})
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(vehicleSchema, req)
const vehicle = await service.createVehicle(body, req.companyId)
created(res, vehicle)
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const vehicle = await service.getVehicle(id, req.companyId)
ok(res, vehicle)
} catch (err) { next(err) }
})
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(vehicleSchema.partial(), req)
const vehicle = await service.updateVehicle(id, req.companyId, body)
ok(res, vehicle)
} catch (err) { next(err) }
})
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteVehicle(id, req.companyId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/:id/photos', requireRole('MANAGER'), imageUpload.array('photos', 10), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const files = req.files as Express.Multer.File[]
assertImageFiles(files)
const updated = await service.uploadPhotos(id, req.companyId, files)
ok(res, updated)
} catch (err) { next(err) }
})
router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, idx } = parseParams(photoIdxSchema, req)
const updated = await service.deletePhoto(id, req.companyId, parseInt(idx, 10))
ok(res, updated)
} catch (err) { next(err) }
})
router.patch('/:id/publish', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { isPublished } = parseBody(publishSchema, req)
await service.setPublished(id, req.companyId, isPublished)
ok(res, { success: true, isPublished })
} catch (err) { next(err) }
})
router.get('/:id/availability', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { startDate, endDate } = parseQuery(availabilityQuerySchema, req)
const result = await service.checkAvailability(id, req.companyId, startDate, endDate)
ok(res, result)
} catch (err) { next(err) }
})
router.get('/:id/calendar', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { year, month } = parseQuery(calendarQuerySchema, req)
const events = await service.getCalendar(id, req.companyId, year, month)
ok(res, events)
} catch (err) { next(err) }
})
router.post('/:id/calendar/blocks', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(calendarBlockSchema, req)
const block = await service.createCalendarBlock(id, req.companyId, body)
created(res, block)
} catch (err) { next(err) }
})
router.delete('/:id/calendar/blocks/:blockId', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, blockId } = parseParams(blockIdParamSchema, req)
await service.deleteCalendarBlock(id, req.companyId, blockId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.get('/:id/maintenance', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const logs = await service.getMaintenanceLogs(id)
ok(res, logs)
} catch (err) { next(err) }
})
router.post('/:id/maintenance', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(maintenanceLogSchema, req)
const log = await service.createMaintenanceLog(id, req.companyId, body)
created(res, log)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,61 @@
import { z } from 'zod'
export 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(),
})
export const listQuerySchema = z.object({
status: z.string().optional(),
category: z.string().optional(),
published: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const availabilityQuerySchema = z.object({
startDate: z.string(),
endDate: z.string(),
})
export const calendarQuerySchema = z.object({
year: z.coerce.number().int(),
month: z.coerce.number().int().min(1).max(12),
})
export const calendarBlockSchema = 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'),
})
export const maintenanceLogSchema = 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(),
})
export const publishSchema = z.object({ isPublished: z.boolean() })
export const idParamSchema = z.object({ id: z.string() })
export const photoIdxSchema = z.object({ id: z.string(), idx: z.string() })
export const blockIdParamSchema = z.object({ id: z.string(), blockId: z.string() })
@@ -0,0 +1,126 @@
import { uploadImage } from '../../lib/storage'
import { NotFoundError, ValidationError } from '../../http/errors'
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
import * as repo from './vehicle.repo'
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
const { status, category, published } = query
const where: any = { companyId }
if (status) where.status = status
if (category) where.category = category
if (published !== undefined) where.isPublished = published === 'true'
const [vehicles, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
return presentVehicleList(vehicles.map(presentVehicle), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
}
export async function getVehicle(id: string, companyId: string) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
return presentVehicle(vehicle)
}
export async function createVehicle(data: any, companyId: string) {
return presentVehicle(await repo.create({ ...data, companyId }))
}
export async function updateVehicle(id: string, companyId: string, data: any) {
const patch = { ...data }
if (patch.status === 'MAINTENANCE' || patch.status === 'OUT_OF_SERVICE') {
patch.isPublished = false
} else if (patch.status === 'AVAILABLE' || patch.status === 'RENTED') {
patch.isPublished = true
}
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found')
return presentVehicle(await repo.updateById(id, patch))
}
export async function deleteVehicle(id: string, companyId: string) {
return repo.softDelete(id, companyId)
}
export async function uploadPhotos(id: string, companyId: string, files: Express.Multer.File[]) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const urls = await Promise.all(files.map((f) => uploadImage(f.buffer, `companies/${companyId}/vehicles`)))
return presentVehicle(await repo.updateById(id, { photos: [...vehicle.photos, ...urls] }))
}
export async function deletePhoto(id: string, companyId: string, idx: number) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const photos = vehicle.photos.filter((_: string, i: number) => i !== idx)
return presentVehicle(await repo.updateById(id, { photos }))
}
export async function setPublished(id: string, companyId: string, isPublished: boolean) {
await repo.setPublished(id, companyId, isPublished)
}
export async function checkAvailability(id: string, companyId: string, startDate: string, endDate: string) {
const start = new Date(startDate)
const end = new Date(endDate)
const [conflicts, blocks] = await Promise.all([
repo.findReservationConflicts(id, companyId, start, end),
repo.findCalendarBlockConflicts(id, start, end),
])
return { available: conflicts.length === 0 && blocks.length === 0, conflicts, blocks }
}
export async function getCalendar(id: string, companyId: string, year: number, month: number) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const rangeStart = new Date(year, month - 1, 1)
const rangeEnd = new Date(year, month, 1)
const [reservations, blocks] = await repo.findCalendarEvents(id, companyId, rangeStart, rangeEnd)
return [
...reservations.map((r: any) => ({
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: any) => ({
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'),
})),
]
}
export async function createCalendarBlock(id: string, companyId: string, data: { startDate: string; endDate: string; reason?: string; type?: 'MANUAL' | 'MAINTENANCE' }) {
const start = new Date(data.startDate)
const end = new Date(data.endDate)
if (end <= start) throw new ValidationError('endDate must be after startDate')
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
return repo.createCalendarBlock({ vehicleId: id, startDate: start, endDate: end, reason: data.reason, type: data.type ?? 'MANUAL' })
}
export async function deleteCalendarBlock(vehicleId: string, companyId: string, blockId: string) {
const vehicle = await repo.findById(vehicleId, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
await repo.deleteCalendarBlock(blockId, vehicleId)
}
export async function getMaintenanceLogs(id: string) {
return repo.findMaintenanceLogs(id)
}
export async function createMaintenanceLog(id: string, companyId: string, data: any) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
return repo.createMaintenanceLog({
...data,
vehicleId: id,
performedAt: new Date(data.performedAt),
nextDueAt: data.nextDueAt ? new Date(data.nextDueAt) : undefined,
})
}
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as repo from './vehicle.repo'
import * as service from './vehicle.service'
vi.mock('./vehicle.repo')
vi.mock('../../lib/storage', () => ({
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/vehicles/test.jpg'),
}))
const mockVehicle = {
id: 'veh_1',
companyId: 'comp_1',
make: 'Toyota',
model: 'Camry',
year: 2022,
licensePlate: 'ABC-123',
status: 'AVAILABLE',
isPublished: true,
photos: [],
dailyRate: 500,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('vehicle.service', () => {
describe('createVehicle', () => {
it('creates a vehicle with companyId', async () => {
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
const result = await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123', dailyRate: 500 }, 'comp_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
expect(result).toEqual(mockVehicle)
})
})
describe('updateVehicle', () => {
it('throws NotFoundError when vehicle does not belong to company', async () => {
vi.mocked(repo.findFirst).mockResolvedValue(null)
await expect(service.updateVehicle('veh_1', 'comp_1', { make: 'Honda' })).rejects.toThrow('Vehicle not found')
})
it('sets isPublished=false when status is MAINTENANCE', async () => {
vi.mocked(repo.findFirst).mockResolvedValue(mockVehicle as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockVehicle, status: 'MAINTENANCE', isPublished: false } as any)
await service.updateVehicle('veh_1', 'comp_1', { status: 'MAINTENANCE' })
expect(repo.updateById).toHaveBeenCalledWith('veh_1', expect.objectContaining({ isPublished: false }))
})
it('sets isPublished=true when status is AVAILABLE', async () => {
vi.mocked(repo.findFirst).mockResolvedValue({ ...mockVehicle, status: 'MAINTENANCE' } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockVehicle, status: 'AVAILABLE', isPublished: true } as any)
await service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' })
expect(repo.updateById).toHaveBeenCalledWith('veh_1', expect.objectContaining({ isPublished: true }))
})
})
describe('uploadPhotos', () => {
it('appends new photo URLs to existing photos', async () => {
const vehicleWithPhotos = { ...mockVehicle, photos: ['http://localhost:4000/storage/vehicles/existing.jpg'] }
vi.mocked(repo.findById).mockResolvedValue(vehicleWithPhotos as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...vehicleWithPhotos, photos: [...vehicleWithPhotos.photos, 'http://localhost:4000/storage/vehicles/test.jpg'] } as any)
await service.uploadPhotos('veh_1', 'comp_1', [{ buffer: Buffer.from('') } as any])
expect(repo.updateById).toHaveBeenCalledWith('veh_1', {
photos: ['http://localhost:4000/storage/vehicles/existing.jpg', 'http://localhost:4000/storage/vehicles/test.jpg'],
})
})
})
describe('deletePhoto', () => {
it('removes photo at the given index', async () => {
const photos = ['url_0', 'url_1', 'url_2']
vi.mocked(repo.findById).mockResolvedValue({ ...mockVehicle, photos } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockVehicle, photos: ['url_0', 'url_2'] } as any)
await service.deletePhoto('veh_1', 'comp_1', 1)
expect(repo.updateById).toHaveBeenCalledWith('veh_1', { photos: ['url_0', 'url_2'] })
})
})
describe('checkAvailability', () => {
it('returns available=true when no conflicts', async () => {
vi.mocked(repo.findReservationConflicts).mockResolvedValue([])
vi.mocked(repo.findCalendarBlockConflicts).mockResolvedValue([])
const result = await service.checkAvailability('veh_1', 'comp_1', '2025-06-01T00:00:00Z', '2025-06-07T00:00:00Z')
expect(result.available).toBe(true)
expect(result.conflicts).toHaveLength(0)
expect(result.blocks).toHaveLength(0)
})
it('returns available=false when reservation conflict exists', async () => {
vi.mocked(repo.findReservationConflicts).mockResolvedValue([{ id: 'res_1', startDate: new Date(), endDate: new Date(), status: 'CONFIRMED' }] as any)
vi.mocked(repo.findCalendarBlockConflicts).mockResolvedValue([])
const result = await service.checkAvailability('veh_1', 'comp_1', '2025-06-01T00:00:00Z', '2025-06-07T00:00:00Z')
expect(result.available).toBe(false)
expect(result.conflicts).toHaveLength(1)
})
it('returns available=false when calendar block conflict exists', async () => {
vi.mocked(repo.findReservationConflicts).mockResolvedValue([])
vi.mocked(repo.findCalendarBlockConflicts).mockResolvedValue([{ id: 'block_1', startDate: new Date(), endDate: new Date(), type: 'MANUAL', reason: null }] as any)
const result = await service.checkAvailability('veh_1', 'comp_1', '2025-06-01T00:00:00Z', '2025-06-07T00:00:00Z')
expect(result.available).toBe(false)
expect(result.blocks).toHaveLength(1)
})
})
describe('createCalendarBlock', () => {
it('throws ValidationError when endDate is before startDate', async () => {
await expect(
service.createCalendarBlock('veh_1', 'comp_1', { startDate: '2025-06-07T00:00:00Z', endDate: '2025-06-01T00:00:00Z' }),
).rejects.toThrow('endDate must be after startDate')
})
it('creates a calendar block', async () => {
vi.mocked(repo.findById).mockResolvedValue(mockVehicle as any)
vi.mocked(repo.createCalendarBlock).mockResolvedValue({ id: 'block_1' } as any)
const result = await service.createCalendarBlock('veh_1', 'comp_1', {
startDate: '2025-06-01T00:00:00Z',
endDate: '2025-06-07T00:00:00Z',
type: 'MANUAL',
})
expect(result).toEqual({ id: 'block_1' })
})
})
})