File

src/tracking/gps-simulator.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService, trackingGateway: TrackingGateway)
Parameters :
Name Type Optional
prisma PrismaService No
trackingGateway TrackingGateway No

Methods

onModuleDestroy
onModuleDestroy()
Returns : void
Async onModuleInit
onModuleInit()
Returns : any
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TrackingGateway } from './tracking.gateway';

/**
 * Per-organization GPS simulator.
 *
 * The simulator boots once at app start and ticks every 5 seconds. Each
 * tick it iterates organizations and only moves vehicles for orgs whose
 * effective tracking mode is `demo` (org.settings.tracking.mode === 'demo'
 * OR no tracking config at all OR live mode without verified telematics).
 *
 * Why per-org: a multi-tenant deployment may have one customer running
 * the demo simulator (sandbox / sales pitch) and another customer pulling
 * real positions from Mix Telematics — we must never let the simulator
 * stomp on real GPS data, and we must never make a real customer's map
 * stand still just because the demo customer wants animation.
 *
 * Routes: vehicles are distributed across multiple Kenyan road corridors
 * so the demo map looks like a real fleet (Nairobi-Mombasa, Nairobi-Thika,
 * Nairobi-Naivasha-Nakuru, Nairobi-Nyeri, Nairobi-Kisumu). Each vehicle
 * is assigned a route based on its current position so the movement
 * looks plausible relative to where the dispatcher last saw it.
 */

/** A GPS coordinate pair [latitude, longitude] in decimal degrees. */
type LatLng = [number, number];

/**
 * Pre-defined road corridors across Kenya used for realistic vehicle movement.
 * Each route is an array of waypoints that vehicles interpolate between.
 * Routes are bidirectional (vehicles reverse direction at endpoints).
 */
const KENYA_ROUTES: Record<string, LatLng[]> = {
  // Nairobi → Mombasa A109 (the original highway route)
  nairobi_mombasa: [
    [-1.2921, 36.8219],  // Nairobi CBD
    [-1.3350, 36.8500],  // Athi River turnoff
    [-1.4500, 37.0700],  // Machakos junction
    [-1.6200, 37.2500],  // Sultan Hamud
    [-2.0400, 37.5200],  // Kibwezi
    [-2.2800, 37.6200],  // Mtito Andei
    [-2.6300, 37.8200],  // Man Eater area
    [-2.9500, 38.1000],  // Voi
    [-3.2200, 38.5500],  // Taru
    [-3.4500, 38.9800],  // Samburu
    [-3.6300, 39.2300],  // Mariakani
    [-3.8200, 39.4500],  // Mazeras
    [-4.0435, 39.6682],  // Mombasa
  ],
  // Nairobi → Thika A2 (short urban-to-industrial)
  nairobi_thika: [
    [-1.2921, 36.8219],  // Nairobi CBD
    [-1.2400, 36.8800],  // Roysambu
    [-1.2200, 36.8950],  // Kasarani
    [-1.1894, 36.9300],  // Ruiru
    [-1.1500, 36.9650],  // Juja
    [-1.0833, 37.0167],  // Witeithie
    [-1.0333, 37.0833],  // Thika
  ],
  // Nairobi → Naivasha → Nakuru (B3 → A104 escarpment)
  nairobi_nakuru: [
    [-1.2921, 36.8219],  // Nairobi CBD
    [-1.2700, 36.7400],  // Westlands
    [-1.2500, 36.6800],  // Limuru turnoff
    [-1.0900, 36.6200],  // Limuru
    [-0.9000, 36.5500],  // Kinungi (Rift Valley viewpoint)
    [-0.7167, 36.4333],  // Naivasha
    [-0.5167, 36.3000],  // Gilgil
    [-0.3083, 36.0667],  // Nakuru
  ],
  // Nairobi → Murang'a → Nyeri (Central Kenya tea country)
  nairobi_nyeri: [
    [-1.2921, 36.8219],  // Nairobi CBD
    [-1.1894, 36.9300],  // Ruiru
    [-1.0333, 37.0833],  // Thika
    [-0.7167, 37.1500],  // Murang'a
    [-0.4833, 37.1333],  // Karatina
    [-0.4167, 36.9500],  // Nyeri
    [-0.3925, 36.9580],  // Mweiga
  ],
  // Nakuru → Kericho → Kisumu (A104 west)
  nakuru_kisumu: [
    [-0.3083, 36.0667],  // Nakuru
    [-0.2500, 35.7800],  // Molo
    [-0.3667, 35.2833],  // Kericho
    [-0.2700, 35.0500],  // Awasi
    [-0.1000, 34.9000],  // Ahero
    [-0.0917, 34.7680],  // Kisumu
  ],
  // Thika → Embu → Meru (eastern Mt Kenya circuit)
  thika_meru: [
    [-1.0333, 37.0833],  // Thika
    [-0.8500, 37.2500],  // Kabati
    [-0.6450, 37.1450],  // Kiriaini
    [-0.5310, 37.4500],  // Embu
    [-0.3500, 37.5500],  // Chuka
    [-0.0500, 37.6500],  // Meru
  ],
  // Local Nairobi loop (CBD → Industrial → Karen → Westlands → CBD)
  nairobi_loop: [
    [-1.2864, 36.8172],  // CBD
    [-1.3081, 36.8511],  // Industrial Area
    [-1.3210, 36.7060],  // Karen
    [-1.2655, 36.8025],  // Westlands
    [-1.2245, 36.8856],  // Kasarani
    [-1.2864, 36.8172],  // CBD (loop back)
  ],
};

const ROUTE_KEYS = Object.keys(KENYA_ROUTES);

/** In-memory state for a single simulated vehicle's position along a route. */
interface SimulatedVehicle {
  vehicleId: string;
  organizationId: string;
  unitNumber: string;
  routeKey: string;
  routeIndex: number;     // current segment index
  progress: number;       // 0-1 along current segment
  direction: 1 | -1;     // 1 = forward, -1 = backward
  speedKmh: number;
}

@Injectable()
export class GpsSimulatorService implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(GpsSimulatorService.name);
  private intervalId: NodeJS.Timeout | null = null;
  // vehicleId → simulated state. Rebuilt on each tick for any new IN_TRANSIT
  // vehicles, so newly-dispatched trucks pick up movement automatically.
  private state = new Map<string, SimulatedVehicle>();
  // Reentrancy guard. With ~60 vehicles per org, one tick can take 6+
  // seconds against Azure Postgres. Without this guard, tick #2 would
  // start while #1 is still mid-loop, both holding locks on the same
  // vehicle/gps_event rows, and Postgres would deadlock one of them.
  private running = false;
  private readonly TICK_INTERVAL_MS = 5000; // 5 seconds

  constructor(
    private readonly prisma: PrismaService,
    private readonly trackingGateway: TrackingGateway,
  ) {}

  async onModuleInit() {
    // The legacy ENABLE_GPS_SIMULATOR env var still acts as a kill switch
    // for environments where you want to be 100% sure no fake data ever
    // gets written (e.g. a customer-facing prod cluster with strict data
    // requirements). When unset OR true, the simulator runs but only
    // touches orgs with tracking.mode=demo, so it's safe by default.
    const killed = process.env.ENABLE_GPS_SIMULATOR === 'false';
    if (killed) {
      this.logger.log(
        'GPS Simulator hard-disabled via ENABLE_GPS_SIMULATOR=false',
      );
      return;
    }

    this.logger.log(
      `GPS Simulator starting (per-org demo mode, tick=${this.TICK_INTERVAL_MS}ms)`,
    );
    this.intervalId = setInterval(() => this.tick(), this.TICK_INTERVAL_MS);
  }

  onModuleDestroy() {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
      this.logger.log('GPS Simulator stopped');
    }
  }

  /**
   * Decide whether an org's vehicles should be simulated.
   *
   * Returns true when:
   *   - org.settings.tracking.mode is 'demo' (or unset → default demo)
   *   - OR org.settings.tracking.mode is 'live' but no verified telematics
   *     (defensive fallback — never let a live-but-broken org show a
   *     dead map; the simulator keeps the demo experience alive until
   *     they fix their credentials)
   */
  private isOrgInDemoMode(settings: any): boolean {
    const requested = settings?.tracking?.mode || 'demo';
    if (requested === 'demo') return true;

    // requested === 'live'
    const t = settings?.telematics;
    if (!t || !t.provider) return true; // no provider yet → demo
    const hasCreds = !!(t.apiKey || t.clientSecret || t.password);
    const verified = t.lastTestStatus === 'ok';
    if (!hasCreds || !verified) return true; // not verified → demo
    return false; // truly live
  }

  /**
   * One tick: load all orgs, decide which are in demo mode, and move
   * their vehicles. Live orgs are skipped — the LiveTelematicsWorker
   * handles those.
   *
   * Tenant context: this worker runs OUTSIDE the HTTP request lifecycle,
   * so the AsyncLocalStorage tenant context that PrismaService normally
   * uses is empty. Without it, the `vehicles` table's RLS policy returns
   * ZERO rows (fail-closed). We solve this by wrapping each org's work
   * in a Prisma `$transaction` and calling
   *   SELECT set_config('app.current_org_id', $1, true)
   * inside it — this gives the transaction's connection the GUC the
   * RLS policy needs, scoped to the tx so it can't bleed into other
   * concurrent queries.
   */
  private async tick() {
    if (this.running) {
      // Previous tick still in flight (slow Postgres, big fleet, or
      // both). Skip this interval rather than racing it.
      return;
    }
    this.running = true;
    try {
      await this.tickInner();
    } finally {
      this.running = false;
    }
  }

  private async tickInner() {
    let orgs: Array<{ id: string; settings: any }> = [];
    try {
      // organizations is intentionally excluded from RLS so this read
      // works without tenant context — see PrismaService docs.
      orgs = await this.prisma.organization.findMany({
        select: { id: true, settings: true },
      });
    } catch (err: any) {
      this.logger.error(`Simulator: failed to list orgs: ${err?.message || err}`);
      return;
    }

    const demoOrgs = orgs.filter(o => this.isOrgInDemoMode(o.settings));
    if (demoOrgs.length === 0) return;

    const allLiveIds = new Set<string>();

    for (const org of demoOrgs) {
      try {
        await this.tickOrgBatched(org.id, allLiveIds);
      } catch (err: any) {
        this.logger.error(`Simulator: org ${org.id} tick failed: ${err?.message || err}`);
      }
    }

    // Drop sim state for vehicles we no longer track (deleted, moved
    // into MAINTENANCE, or whose org switched to live).
    for (const id of this.state.keys()) {
      if (!allLiveIds.has(id)) this.state.delete(id);
    }
  }

  /**
   * Tick a single org's vehicles using a single batched UPDATE.
   *
   * Approach:
   *   1. Open one transaction, set the RLS GUC.
   *   2. SELECT all eligible vehicles for the org (1 round-trip).
   *   3. Compute the new position for every vehicle in memory (no DB).
   *   4. Issue a SINGLE `UPDATE ... FROM (VALUES ...)` that touches every
   *      moved row in one statement (1 round-trip).
   *
   * Total: 3 DB round-trips per org per tick (set_config + SELECT + UPDATE)
   * regardless of fleet size, vs the old approach of 1 + N×2 round-trips
   * (124 for a 62-vehicle fleet). This is what makes the simulator
   * actually fit inside the 5-second tick budget for non-trivial fleets.
   *
   * We deliberately skip writing per-tick gps_events rows here — the
   * map only needs vehicles.current_lat/lng/last_gps_at to render, and
   * writing 60 history rows every 5 seconds is wasted work for a demo.
   * Real telematics ingestion (LiveTelematicsWorker) DOES write
   * gps_events because that's the actual position history.
   */
  private async tickOrgBatched(orgId: string, allLiveIds: Set<string>) {
    type VehicleRow = {
      id: string;
      unit_number: string;
      current_lat: number | null;
      current_lng: number | null;
    };

    let vehicles: VehicleRow[] = [];

    // Step 1: open a tx, set RLS, read vehicles. Quick — just 2 queries.
    await (this.prisma as any).$transaction(
      async (tx: any) => {
        await tx.$queryRaw`SELECT set_config('app.current_org_id', ${orgId}, true)`;
        vehicles = await tx.$queryRaw<VehicleRow[]>`
          SELECT id, unit_number, current_lat, current_lng
          FROM vehicles
          WHERE status NOT IN ('MAINTENANCE', 'OUT_OF_SERVICE')
        `;
      },
      { timeout: 15_000, maxWait: 10_000 },
    );

    if (vehicles.length === 0) return;

    // Step 2: compute every new position in memory.
    type Patch = { id: string; lat: number; lng: number; speed: number; heading: number };
    const patches: Patch[] = [];
    for (const v of vehicles) {
      let sim = this.state.get(v.id);
      if (!sim) {
        sim = this.assignInitialState({
          id: v.id,
          organizationId: orgId,
          unitNumber: v.unit_number,
          currentLat: v.current_lat,
          currentLng: v.current_lng,
        });
        this.state.set(v.id, sim);
      }
      const next = this.computeNextPosition(sim);
      if (next) {
        patches.push({ id: v.id, ...next });
        allLiveIds.add(v.id);
      }
    }

    if (patches.length === 0) return;

    // Step 3: one batched UPDATE for the whole org. Build a parameterized
    // VALUES list — Prisma's $executeRaw with template strings only
    // takes a fixed number of slots, so we use $executeRawUnsafe with
    // a hand-built parameter list. Values are validated as numbers
    // before composing, so SQL injection is impossible.
    const params: any[] = [];
    const valuesSql = patches
      .map((p, i) => {
        // Each row contributes 5 params: id, lat, lng, speed, last_gps
        const idx = i * 5;
        params.push(p.id, p.lat, p.lng, p.speed, new Date());
        return `($${idx + 1}::text, $${idx + 2}::float8, $${idx + 3}::float8, $${idx + 4}::float8, $${idx + 5}::timestamp)`;
      })
      .join(',');

    const sql = `
      UPDATE vehicles AS v SET
        current_lat = u.lat,
        current_lng = u.lng,
        current_speed = u.speed,
        last_gps_at = u.ts,
        updated_at = NOW()
      FROM (VALUES ${valuesSql}) AS u(id, lat, lng, speed, ts)
      WHERE v.id = u.id
    `;

    await (this.prisma as any).$transaction(
      async (tx: any) => {
        await tx.$queryRaw`SELECT set_config('app.current_org_id', ${orgId}, true)`;
        await tx.$executeRawUnsafe(sql, ...params);
      },
      { timeout: 15_000, maxWait: 10_000 },
    );

    // Step 4: broadcast over WebSocket so any open dispatcher map gets
    // the smooth animation without polling.
    for (const p of patches) {
      const sim = this.state.get(p.id)!;
      this.trackingGateway.broadcastPosition(p.id, {
        lat: p.lat,
        lng: p.lng,
        speed: p.speed,
        heading: p.heading,
        timestamp: new Date(),
        unitNumber: sim.unitNumber,
      });
    }
  }

  /**
   * Pure function — advances the sim state and returns the new
   * lat/lng/speed/heading. No DB writes here, so we can call this for
   * every vehicle in memory before issuing a single batched UPDATE.
   */
  private computeNextPosition(vehicle: SimulatedVehicle): { lat: number; lng: number; speed: number; heading: number } | null {
    const route = KENYA_ROUTES[vehicle.routeKey];
    if (!route) return null;

    const from = route[vehicle.routeIndex];
    const to = route[vehicle.routeIndex + 1];
    if (!from || !to) {
      vehicle.routeIndex = Math.max(0, Math.min(vehicle.routeIndex, route.length - 2));
      return null;
    }

    const segmentDistKm = this.haversineDistance(from[0], from[1], to[0], to[1]);
    vehicle.speedKmh = Math.max(40, Math.min(110, vehicle.speedKmh + (Math.random() - 0.5) * 8));

    const distanceTraveledKm = (vehicle.speedKmh / 3600) * (this.TICK_INTERVAL_MS / 1000);
    const progressDelta = distanceTraveledKm / segmentDistKm;
    vehicle.progress += progressDelta * vehicle.direction;

    if (vehicle.progress >= 1) {
      vehicle.progress = 0;
      vehicle.routeIndex += 1;
      if (vehicle.routeIndex >= route.length - 1) {
        vehicle.routeIndex = route.length - 2;
        vehicle.direction = -1;
        vehicle.progress = 1;
      }
    } else if (vehicle.progress <= 0) {
      vehicle.progress = 1;
      vehicle.routeIndex -= 1;
      if (vehicle.routeIndex < 0) {
        vehicle.routeIndex = 0;
        vehicle.direction = 1;
        vehicle.progress = 0;
      }
    }

    const currentFrom = route[vehicle.routeIndex];
    const currentTo = route[vehicle.routeIndex + 1] || route[vehicle.routeIndex];
    const lat = currentFrom[0] + (currentTo[0] - currentFrom[0]) * vehicle.progress + (Math.random() - 0.5) * 0.0008;
    const lng = currentFrom[1] + (currentTo[1] - currentFrom[1]) * vehicle.progress + (Math.random() - 0.5) * 0.0008;

    const heading = this.calculateBearing(currentFrom[0], currentFrom[1], currentTo[0], currentTo[1]);
    const adjustedHeading = vehicle.direction === 1 ? heading : (heading + 180) % 360;

    return { lat, lng, speed: vehicle.speedKmh, heading: adjustedHeading };
  }

  /** Pick the closest Kenyan route to wherever this vehicle currently is. */
  private assignInitialState(v: {
    id: string;
    organizationId: string;
    unitNumber: string;
    currentLat: number | null;
    currentLng: number | null;
  }): SimulatedVehicle {
    let routeKey = ROUTE_KEYS[Math.abs(this.hashId(v.id)) % ROUTE_KEYS.length];
    let routeIndex = 0;
    let progress = Math.random();

    if (v.currentLat != null && v.currentLng != null) {
      // Find the closest waypoint across all routes — that's the most
      // believable starting point for this vehicle's first tick.
      let bestDist = Infinity;
      for (const [key, pts] of Object.entries(KENYA_ROUTES)) {
        for (let i = 0; i < pts.length; i++) {
          const d = this.squaredDist(v.currentLat, v.currentLng, pts[i][0], pts[i][1]);
          if (d < bestDist) {
            bestDist = d;
            routeKey = key;
            routeIndex = Math.min(i, pts.length - 2);
            progress = 0;
          }
        }
      }
    }

    return {
      vehicleId: v.id,
      organizationId: v.organizationId,
      unitNumber: v.unitNumber,
      routeKey,
      routeIndex,
      progress,
      direction: Math.random() > 0.5 ? 1 : -1,
      speedKmh: 50 + Math.random() * 50, // 50-100 km/h
    };
  }

  private hashId(s: string): number {
    let h = 0;
    for (let i = 0; i < s.length; i++) {
      h = ((h << 5) - h) + s.charCodeAt(i);
      h |= 0;
    }
    return h;
  }

  private squaredDist(lat1: number, lng1: number, lat2: number, lng2: number) {
    const dLat = lat2 - lat1;
    const dLng = lng2 - lng1;
    return dLat * dLat + dLng * dLng;
  }

  /**
   * Calculate great-circle distance between two GPS coordinates in kilometres.
   *
   * @param lat1 - Start latitude in decimal degrees.
   * @param lng1 - Start longitude in decimal degrees.
   * @param lat2 - End latitude in decimal degrees.
   * @param lng2 - End longitude in decimal degrees.
   * @returns Distance in kilometres.
   */
  private haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
    const R = 6371;
    const dLat = this.toRad(lat2 - lat1);
    const dLng = this.toRad(lng2 - lng1);
    const a =
      Math.sin(dLat / 2) ** 2 +
      Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) ** 2;
    return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  }

  /**
   * Calculate the initial bearing (compass heading) between two GPS points.
   *
   * @param lat1 - Start latitude in decimal degrees.
   * @param lng1 - Start longitude in decimal degrees.
   * @param lat2 - End latitude in decimal degrees.
   * @param lng2 - End longitude in decimal degrees.
   * @returns Bearing in degrees (0-360, clockwise from north).
   */
  private calculateBearing(lat1: number, lng1: number, lat2: number, lng2: number): number {
    const dLng = this.toRad(lng2 - lng1);
    const y = Math.sin(dLng) * Math.cos(this.toRad(lat2));
    const x =
      Math.cos(this.toRad(lat1)) * Math.sin(this.toRad(lat2)) -
      Math.sin(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.cos(dLng);
    return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
  }

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

results matching ""

    No results matching ""