File

src/vehicles/vehicles.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

Async bulkUploadFromFile
bulkUploadFromFile(organizationId: string, file: Express.Multer.File)
Parameters :
Name Type Optional
organizationId string No
file Express.Multer.File No
Async create
create(organizationId: string, dto: CreateVehicleDto)
Parameters :
Name Type Optional
organizationId string No
dto CreateVehicleDto No
Returns : unknown
Async createClass
createClass(organizationId: string, data: literal type)
Parameters :
Name Type Optional
organizationId string No
data literal type No
Returns : unknown
Async deleteClass
deleteClass(organizationId: string, id: string)
Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async findAll
findAll(organizationId: string, params: PaginationParams)
Parameters :
Name Type Optional
organizationId string No
params PaginationParams No
Returns : unknown
Async findAllClasses
findAllClasses(organizationId: string)
Parameters :
Name Type Optional
organizationId string No
Returns : unknown
Async findOne
findOne(organizationId: string, id: string)

Org-scoped lookup. findFirst with organizationId in the where clause makes cross-tenant ID-guessing physically impossible.

Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async remove
remove(organizationId: string, id: string)
Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async update
update(organizationId: string, id: string, dto: UpdateVehicleDto)
Parameters :
Name Type Optional
organizationId string No
id string No
dto UpdateVehicleDto No
Returns : unknown
Async updateClass
updateClass(organizationId: string, id: string, data: literal type)
Parameters :
Name Type Optional
organizationId string No
id string No
data literal type No
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 };
  }
}

results matching ""

    No results matching ""