src/vehicles/vehicles.service.ts
Methods |
|
constructor(prisma: PrismaService)
|
||||||
|
Defined in src/vehicles/vehicles.service.ts:28
|
||||||
|
Parameters :
|
| Async bulkUploadFromFile | |||||||||
bulkUploadFromFile(organizationId: string, file: Express.Multer.File)
|
|||||||||
|
Defined in src/vehicles/vehicles.service.ts:140
|
|||||||||
|
Parameters :
Returns :
Promise<BulkUploadResult>
|
| Async create | |||||||||
create(organizationId: string, dto: CreateVehicleDto)
|
|||||||||
|
Defined in src/vehicles/vehicles.service.ts:32
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async createClass | |||||||||
createClass(organizationId: string, data: literal type)
|
|||||||||
|
Defined in src/vehicles/vehicles.service.ts:220
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async deleteClass |
deleteClass(organizationId: string, id: string)
|
|
Defined in src/vehicles/vehicles.service.ts:266
|
|
Returns :
unknown
|
| Async findAll | |||||||||
findAll(organizationId: string, params: PaginationParams)
|
|||||||||
|
Defined in src/vehicles/vehicles.service.ts:47
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async findAllClasses | ||||||
findAllClasses(organizationId: string)
|
||||||
|
Defined in src/vehicles/vehicles.service.ts:238
|
||||||
|
Parameters :
Returns :
unknown
|
| Async findOne |
findOne(organizationId: string, id: string)
|
|
Defined in src/vehicles/vehicles.service.ts:82
|
|
Org-scoped lookup. findFirst with organizationId in the where clause makes cross-tenant ID-guessing physically impossible.
Returns :
unknown
|
| Async remove |
remove(organizationId: string, id: string)
|
|
Defined in src/vehicles/vehicles.service.ts:117
|
|
Returns :
unknown
|
| Async update | ||||||||||||
update(organizationId: string, id: string, dto: UpdateVehicleDto)
|
||||||||||||
|
Defined in src/vehicles/vehicles.service.ts:97
|
||||||||||||
|
Parameters :
Returns :
unknown
|
| Async updateClass |
updateClass(organizationId: string, id: string, data: literal type)
|
|
Defined in src/vehicles/vehicles.service.ts:249
|
|
Returns :
unknown
|
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import {
buildPaginationQuery,
buildPaginationMeta,
PaginationParams,
} from '../common/utils/pagination.util';
import { BulkUploadService, VEHICLE_MAPPINGS, BulkUploadResult } from '../common/services/bulk-upload.service';
/**
* Trip statuses considered "in flight" — a vehicle with a trip in any of
* these can't be deleted because the trip would silently lose its
* vehicle reference (the FK is ON DELETE SET NULL).
*/
const ACTIVE_TRIP_STATUSES = ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP'] as const;
@Injectable()
export class VehiclesService {
private readonly logger = new Logger(VehiclesService.name);
private readonly bulkUpload = new BulkUploadService();
constructor(private prisma: PrismaService) {}
async create(organizationId: string, dto: CreateVehicleDto) {
// Per-org uniqueness on unitNumber (composite unique constraint
// backs this; the explicit check just gives a friendlier error).
const existing = await this.prisma.vehicle.findFirst({
where: { organizationId, unitNumber: dto.unitNumber },
});
if (existing) {
throw new ConflictException(`Unit number "${dto.unitNumber}" already exists in this organization`);
}
return this.prisma.vehicle.create({
data: { organizationId, ...dto },
});
}
async findAll(organizationId: string, params: PaginationParams) {
const { skip, take, orderBy, page, limit } = buildPaginationQuery(params);
const where: any = { organizationId };
if (params.search) {
where.OR = [
{ unitNumber: { contains: params.search, mode: 'insensitive' } },
{ licensePlate: { contains: params.search, mode: 'insensitive' } },
{ name: { contains: params.search, mode: 'insensitive' } },
];
}
const [items, total] = await Promise.all([
this.prisma.vehicle.findMany({
where,
skip,
take,
orderBy,
include: {
vehicleClass: { select: { id: true, name: true, code: true, tonnage: true } },
transporter: { select: { id: true, name: true, code: true } },
driver: { select: { id: true, firstName: true, lastName: true } },
_count: { select: { trips: true } },
},
}),
this.prisma.vehicle.count({ where }),
]);
return { data: items, meta: buildPaginationMeta(total, page, limit) };
}
/**
* Org-scoped lookup. findFirst with organizationId in the where clause
* makes cross-tenant ID-guessing physically impossible.
*/
async findOne(organizationId: string, id: string) {
const vehicle = await this.prisma.vehicle.findFirst({
where: { id, organizationId },
include: {
vehicleClass: true,
transporter: { select: { id: true, name: true, code: true } },
driver: { select: { id: true, firstName: true, lastName: true, phone: true } },
trips: { take: 5, orderBy: { createdAt: 'desc' } },
_count: { select: { trips: true, gpsEvents: true } },
},
});
if (!vehicle) throw new NotFoundException('Vehicle not found');
return vehicle;
}
async update(organizationId: string, id: string, dto: UpdateVehicleDto) {
await this.findOne(organizationId, id);
// If unitNumber is changing, re-check per-org uniqueness.
if (dto.unitNumber) {
const existing = await this.prisma.vehicle.findFirst({
where: {
organizationId,
unitNumber: dto.unitNumber,
NOT: { id },
},
});
if (existing) {
throw new ConflictException(`Another vehicle with unit number "${dto.unitNumber}" already exists`);
}
}
return this.prisma.vehicle.update({ where: { id }, data: dto });
}
async remove(organizationId: string, id: string) {
await this.findOne(organizationId, id);
// Block delete if the vehicle has any in-flight trip. Past trips are
// OK to keep — the FK is `ON DELETE SET NULL` so the trip history
// survives without a vehicle reference.
const inFlight = await this.prisma.trip.count({
where: {
organizationId,
vehicleId: id,
status: { in: [...ACTIVE_TRIP_STATUSES] as any },
},
});
if (inFlight > 0) {
throw new BadRequestException(
`Vehicle has ${inFlight} in-flight trip${inFlight === 1 ? '' : 's'}. Complete or reassign before deleting.`,
);
}
await this.prisma.vehicle.delete({ where: { id } });
return { deleted: true };
}
async bulkUploadFromFile(organizationId: string, file: Express.Multer.File): Promise<BulkUploadResult> {
const { rows, errors } = this.bulkUpload.parseFile(file.buffer, file.originalname, VEHICLE_MAPPINGS);
const created: Record<string, unknown>[] = [];
let skipped = 0;
for (const row of rows) {
const unitNumber = String(row.unitNumber || '').replace(/\s+/g, '').toUpperCase();
if (!unitNumber) {
skipped++;
continue;
}
// Per-org uniqueness check (was previously global — could collide
// across tenants and silently skip)
const existing = await this.prisma.vehicle.findFirst({
where: { organizationId, unitNumber },
});
if (existing) {
skipped++;
continue;
}
// Find or create transporter (org-scoped)
let transporterId: string | undefined;
const transporterCode = row.transporterCode as string;
if (transporterCode) {
const t = await this.prisma.transporter.findFirst({
where: {
organizationId,
OR: [
{ code: transporterCode.toUpperCase() },
{ name: { contains: transporterCode, mode: 'insensitive' } },
],
},
});
if (t) {
transporterId = t.id;
} else {
const nt = await this.prisma.transporter.create({
data: {
organizationId,
name: transporterCode.toUpperCase(),
code: transporterCode.toUpperCase(),
isActive: true,
},
});
transporterId = nt.id;
}
}
const validTypes = ['DRY_VAN', 'REFRIGERATED', 'FLATBED', 'TANKER', 'CURTAIN_SIDE', 'BOX_TRUCK', 'SPRINTER', 'HAZMAT'];
const vType = validTypes.includes(String(row.type || '')) ? String(row.type) : 'DRY_VAN';
const vehicle = await this.prisma.vehicle.create({
data: {
organizationId,
unitNumber,
licensePlate: (row.licensePlate as string) || unitNumber,
type: vType as any,
status: 'AVAILABLE',
make: (row.make as string) || undefined,
model: (row.model as string) || undefined,
year: (row.year as number) || undefined,
vin: (row.vin as string) || undefined,
maxWeight: (row.maxWeight as number) || undefined,
maxVolume: (row.maxVolume as number) || undefined,
fuelType: (row.fuelType as string) || undefined,
transporterId,
homeSite: (row.homeSite as string) || undefined,
},
});
created.push(vehicle);
}
this.logger.log(`Bulk vehicle upload: ${created.length} created, ${skipped} skipped, ${errors.length} errors`);
return { created: created.length, skipped, errors, records: created, totalRows: rows.length };
}
// ── Vehicle Classes ──────────────────────────────────────────────
async createClass(organizationId: string, data: { name: string; code: string; tonnage: number; description?: string }) {
const existing = await this.prisma.vehicleClass.findFirst({
where: { organizationId, code: data.code },
});
if (existing) throw new ConflictException(`Vehicle class "${data.code}" already exists`);
return this.prisma.vehicleClass.create({
data: {
organizationId,
name: data.name,
code: data.code,
tonnage: data.tonnage,
maxWeightKg: data.tonnage * 1000,
description: data.description,
},
});
}
async findAllClasses(organizationId: string) {
const classes = await this.prisma.vehicleClass.findMany({
where: { organizationId },
orderBy: { tonnage: 'asc' },
include: {
_count: { select: { vehicles: true } },
},
});
return { data: classes };
}
async updateClass(
organizationId: string,
id: string,
data: { name?: string; code?: string; tonnage?: number; description?: string },
) {
const cls = await this.prisma.vehicleClass.findFirst({ where: { id, organizationId } });
if (!cls) throw new NotFoundException('Vehicle class not found');
return this.prisma.vehicleClass.update({
where: { id },
data: {
...data,
maxWeightKg: data.tonnage ? data.tonnage * 1000 : undefined,
},
});
}
async deleteClass(organizationId: string, id: string) {
const cls = await this.prisma.vehicleClass.findFirst({
where: { id, organizationId },
include: { _count: { select: { vehicles: true } } },
});
if (!cls) throw new NotFoundException('Vehicle class not found');
if (cls._count.vehicles > 0) {
throw new ConflictException(
`Cannot delete — ${cls._count.vehicles} vehicles belong to this class. Reassign them first.`,
);
}
await this.prisma.vehicleClass.delete({ where: { id } });
return { deleted: true };
}
}