File

src/tracking/tracking.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService, geofenceService: GeofenceService)
Parameters :
Name Type Optional
prisma PrismaService No
geofenceService GeofenceService No

Methods

Async getTripTracking
getTripTracking(tripId: string)

Get trip tracking data with stops, current vehicle position, and ETA

Parameters :
Name Type Optional
tripId string No
Returns : unknown
Async getVehiclePosition
getVehiclePosition(vehicleId: string)

Get single vehicle position with recent trail

Parameters :
Name Type Optional
vehicleId string No
Returns : unknown
Async getVehiclePositions
getVehiclePositions(orgId: string)

Get current positions for all vehicles in an organization

Parameters :
Name Type Optional
orgId string No
Returns : unknown
Async getVehicleTrail
getVehicleTrail(vehicleId: string, from: Date, to: Date)

Get GPS event trail for a vehicle in a time range

Parameters :
Name Type Optional
vehicleId string No
from Date No
to Date No
Returns : unknown
Async updateVehiclePosition
updateVehiclePosition(vehicleId: string, lat: number, lng: number, speed: number, heading: number, source: string)

Update a vehicle's position and create a GpsEvent record

Parameters :
Name Type Optional Default value
vehicleId string No
lat number No
lng number No
speed number No
heading number No
source string No 'manual'
Returns : unknown
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { GeofenceService } from './geofence.service';

@Injectable()
export class TrackingService {
  private readonly logger = new Logger(TrackingService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly geofenceService: GeofenceService,
  ) {}

  /**
   * Get current positions for all vehicles in an organization
   */
  async getVehiclePositions(orgId: string) {
    const vehicles = await this.prisma.vehicle.findMany({
      where: { organizationId: orgId },
      select: {
        id: true,
        unitNumber: true,
        type: true,
        status: true,
        licensePlate: true,
        currentLat: true,
        currentLng: true,
        currentSpeed: true,
        lastGpsAt: true,
        driverId: true,
        driver: {
          select: {
            id: true,
            firstName: true,
            lastName: true,
            phone: true,
          },
        },
      },
    });

    return vehicles.map((v) => ({
      ...v,
      isOnline: v.lastGpsAt
        ? Date.now() - new Date(v.lastGpsAt).getTime() < 5 * 60 * 1000 // online if GPS update within 5 min
        : false,
    }));
  }

  /**
   * Get single vehicle position with recent trail
   */
  async getVehiclePosition(vehicleId: string) {
    const vehicle = await this.prisma.vehicle.findUnique({
      where: { id: vehicleId },
      include: {
        driver: {
          select: {
            id: true,
            firstName: true,
            lastName: true,
            phone: true,
          },
        },
        trips: {
          where: { status: { in: ['IN_TRANSIT', 'AT_STOP'] } },
          take: 1,
          orderBy: { startDate: 'desc' },
          include: {
            stops: { orderBy: { sequence: 'asc' } },
          },
        },
      },
    });

    if (!vehicle) {
      throw new NotFoundException(`Vehicle ${vehicleId} not found`);
    }

    // Get recent trail (last 2 hours)
    const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
    const trail = await this.prisma.gpsEvent.findMany({
      where: {
        vehicleId,
        timestamp: { gte: twoHoursAgo },
      },
      orderBy: { timestamp: 'asc' },
      select: {
        lat: true,
        lng: true,
        speed: true,
        heading: true,
        timestamp: true,
      },
    });

    return {
      ...vehicle,
      trail,
      isOnline: vehicle.lastGpsAt
        ? Date.now() - new Date(vehicle.lastGpsAt).getTime() < 5 * 60 * 1000
        : false,
    };
  }

  /**
   * Update a vehicle's position and create a GpsEvent record
   */
  async updateVehiclePosition(
    vehicleId: string,
    lat: number,
    lng: number,
    speed: number,
    heading: number,
    source = 'manual',
  ) {
    const now = new Date();

    // Update vehicle current position
    const vehicle = await this.prisma.vehicle.update({
      where: { id: vehicleId },
      data: {
        currentLat: lat,
        currentLng: lng,
        currentSpeed: speed,
        lastGpsAt: now,
      },
    });

    // Create GPS event record
    const gpsEvent = await this.prisma.gpsEvent.create({
      data: {
        vehicleId,
        lat,
        lng,
        speed,
        heading,
        timestamp: now,
        source,
      },
    });

    // Check geofences and create events for any transitions
    try {
      const geofenceEvents = await this.geofenceService.checkGeofences(
        vehicleId,
        lat,
        lng,
      );

      for (const gfEvent of geofenceEvents) {
        await this.prisma.gpsEvent.create({
          data: {
            vehicleId,
            lat,
            lng,
            speed: 0,
            heading: 0,
            timestamp: now,
            source: `geofence_${gfEvent.event}`,
          },
        });

        this.logger.log(
          `Geofence ${gfEvent.event}: vehicle ${vehicleId} ${gfEvent.event === 'enter' ? 'entered' : 'exited'} "${gfEvent.name}" (${gfEvent.geofenceId})`,
        );
      }
    } catch (error: unknown) {
      const message = error instanceof Error ? error.message : String(error);
      this.logger.warn(
        `Geofence check failed for vehicle ${vehicleId}: ${message}`,
      );
    }

    this.logger.debug(
      `Updated position for vehicle ${vehicleId}: ${lat}, ${lng} @ ${speed} km/h`,
    );

    return {
      vehicleId,
      lat,
      lng,
      speed,
      heading,
      timestamp: now,
      unitNumber: vehicle.unitNumber,
    };
  }

  /**
   * Get GPS event trail for a vehicle in a time range
   */
  async getVehicleTrail(vehicleId: string, from: Date, to: Date) {
    return this.prisma.gpsEvent.findMany({
      where: {
        vehicleId,
        timestamp: { gte: from, lte: to },
      },
      orderBy: { timestamp: 'asc' },
      select: {
        id: true,
        lat: true,
        lng: true,
        speed: true,
        heading: true,
        timestamp: true,
        source: true,
      },
    });
  }

  /**
   * Get trip tracking data with stops, current vehicle position, and ETA
   */
  async getTripTracking(tripId: string) {
    const trip = await this.prisma.trip.findUnique({
      where: { id: tripId },
      include: {
        stops: { orderBy: { sequence: 'asc' } },
        vehicle: {
          select: {
            id: true,
            unitNumber: true,
            currentLat: true,
            currentLng: true,
            currentSpeed: true,
            lastGpsAt: true,
            driver: {
              select: {
                id: true,
                firstName: true,
                lastName: true,
                phone: true,
              },
            },
          },
        },
        orders: {
          select: {
            id: true,
            orderNumber: true,
            status: true,
            destName: true,
            destCity: true,
          },
        },
      },
    });

    if (!trip) {
      throw new NotFoundException(`Trip ${tripId} not found`);
    }

    // Compute ETA to next stop
    let nextStop = null;
    let etaMinutes: number | null = null;

    if (trip.vehicle?.currentLat && trip.vehicle?.currentLng) {
      // Find the next unvisited stop
      nextStop =
        trip.stops.find((s) => !s.actualArrival) || null;

      if (nextStop?.lat && nextStop?.lng) {
        const distanceKm = this.haversineDistance(
          trip.vehicle.currentLat,
          trip.vehicle.currentLng,
          nextStop.lat,
          nextStop.lng,
        );

        // Use current speed or default 60 km/h
        const speedKmh = trip.vehicle.currentSpeed && trip.vehicle.currentSpeed > 5
          ? trip.vehicle.currentSpeed
          : 60;

        etaMinutes = Math.round((distanceKm / speedKmh) * 60);
      }
    }

    return {
      ...trip,
      nextStop,
      etaMinutes,
      etaTime: etaMinutes ? new Date(Date.now() + etaMinutes * 60 * 1000) : null,
    };
  }

  /**
   * Haversine formula to calculate distance between two GPS coordinates
   */
  private haversineDistance(
    lat1: number,
    lng1: number,
    lat2: number,
    lng2: number,
  ): number {
    const R = 6371; // Earth radius in km
    const dLat = this.toRad(lat2 - lat1);
    const dLng = this.toRad(lng2 - lng1);
    const a =
      Math.sin(dLat / 2) * Math.sin(dLat / 2) +
      Math.cos(this.toRad(lat1)) *
        Math.cos(this.toRad(lat2)) *
        Math.sin(dLng / 2) *
        Math.sin(dLng / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return R * c;
  }

  private toRad(deg: number): number {
    return deg * (Math.PI / 180);
  }
}

results matching ""

    No results matching ""