File

src/trips/trips.service.ts

Description

Service responsible for trip lifecycle management, dispatch operations, and route resolution.

All trip mutations enforce tenant isolation via organizationId scoping and Postgres RLS. Critical operations (create, dispatch) use serialisable transaction isolation to prevent race conditions between concurrent dispatchers.

Index

Methods

Constructor

constructor(prisma: PrismaService, pushService: PushService, routeOptimizer: RouteOptimizer)
Parameters :
Name Type Optional
prisma PrismaService No
pushService PushService No
routeOptimizer RouteOptimizer No

Methods

Async cancel
cancel(organizationId: string, id: string)

Cancel a trip and release all associated resources.

Resets the vehicle to AVAILABLE, releases the bay, and unlinks all orders back to VALIDATED / PENDING status so they can be reassigned.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
id string No
  • The trip's database ID.
Returns : unknown

{ cancelled: true } on success.

Async complete
complete(organizationId: string, id: string)

Mark a trip as completed.

Validates that POD (Proof of Delivery) has been submitted if required. On completion: sets endDate, releases the vehicle to AVAILABLE, releases the bay, and marks all linked orders as DELIVERED with actualDelivery timestamp.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
id string No
  • The trip's database ID.
Returns : unknown

The completed trip.

Async create
create(organizationId: string, dto: CreateTripDto)

Create a new trip with full pre-flight validation.

Wrapped in a Serializable transaction to prevent two dispatchers from simultaneously assigning the same driver or vehicle. Validates:

  • Driver exists, is active, and not on another active trip
  • Vehicle exists, is not on another active trip, and driver matches
  • All linked orders belong to this org and are not already on a trip
  • Total order weight does not exceed vehicle max payload

Route distance/duration is resolved eagerly if enough data is available (lane or explicit stops). Otherwise, resolution is deferred to dispatch.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
dto CreateTripDto No
  • Trip creation payload (vehicle, driver, orders, stops, lane, etc.).
Returns : unknown

The created trip with stops, vehicle, and driver relations.

Async dispatch
dispatch(organizationId: string, id: string)

Dispatch a planned trip — the key operational transition.

Dispatch is the moment the trip becomes "live". This method:

  1. Validates the trip is in PLANNED status with a vehicle assigned
  2. Resolves and persists route distance + duration (requires a lane or explicit stops; fails with a clear message if missing)
  3. Sets status to DISPATCHED with dispatchedAt timestamp
  4. Marks the vehicle as ASSIGNED (waiting at dock)
  5. Updates linked orders to ASSIGNED / DISPATCHED job status
  6. Auto-assigns a loading bay if none is set (e.g., after timeout reallocation)
  7. Marks the assigned bay as OCCUPIED
  8. Sends an FCM push notification to the assigned driver
Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
id string No
  • The trip's database ID.
Returns : unknown

The dispatched trip with relations.

Async findAll
findAll(organizationId: string, params: PaginationParams, driverUserId?: string)

List trips with pagination, enriched with bay deadline information.

When driverUserId is provided (DRIVER role), results are filtered to only show trips assigned to that driver. The driver is matched by email or name against the drivers table.

Each trip with a loading bay and dispatch time is enriched with a bayDeadline ISO timestamp computed from the org's bayTimeout settings, so the driver app can show countdown timers.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
params PaginationParams No
  • Pagination and sorting parameters.
driverUserId string Yes
  • Optional user ID to filter by driver (for DRIVER role).
Returns : unknown

Paginated result with enriched trip data.

Async findOne
findOne(organizationId: string, id: string)

Org-scoped lookup. Cross-tenant ID-guessing impossible.

Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async remove
remove(organizationId: string, id: string)

Delete a trip and clean up all associated resources.

Resets the vehicle (if PLANNED/ASSIGNED), releases the bay, unlinks all orders back to the Jobs board, then deletes the trip record.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
id string No
  • The trip's database ID.
Returns : unknown

{ deleted: true } on success.

Async update
update(organizationId: string, id: string, dto: UpdateTripDto, caller?: literal type)

Update a trip's fields including status transitions.

Status changes are validated by the trip state machine. Specific transition side-effects:

  • IN_TRANSIT: requires all linked orders to have loading confirmed, flips order statuses, releases the dock bay, sets departedAt

Driver self-service: DRIVER role callers can only update status and notes on trips assigned to them.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
id string No
  • The trip's database ID.
dto UpdateTripDto No
  • Partial update payload.
caller literal type Yes
  • Optional caller context for driver guard { userId, role }.
Returns : unknown

The updated trip with relations.

import {
  Injectable,
  NotFoundException,
  BadRequestException,
  Logger,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PushService } from '../notifications/push.service';
import { RouteOptimizer, Stop } from './optimizer/route.optimizer';
import { CreateTripDto } from './dto/create-trip.dto';
import { UpdateTripDto } from './dto/update-trip.dto';
import {
  buildPaginationQuery,
  buildPaginationMeta,
  PaginationParams,
} from '../common/utils/pagination.util';
import { assertTripStatusTransition } from '../common/state-machines';

/**
 * Service responsible for trip lifecycle management, dispatch operations,
 * and route resolution.
 *
 * All trip mutations enforce tenant isolation via `organizationId` scoping
 * and Postgres RLS. Critical operations (create, dispatch) use serialisable
 * transaction isolation to prevent race conditions between concurrent
 * dispatchers.
 *
 * @dependencies
 *   - {@link PrismaService} — tenant-aware database access
 *   - {@link PushService} — FCM push notifications to drivers
 *   - {@link RouteOptimizer} — haversine-based route distance calculation
 */
@Injectable()
export class TripsService {
  private readonly logger = new Logger(TripsService.name);

  constructor(
    private prisma: PrismaService,
    private pushService: PushService,
    private routeOptimizer: RouteOptimizer,
  ) {}

  /**
   * Resolve a trip's route distance + duration from the data that's
   * actually attached to the trip at this point in its lifecycle.
   * Orders never carry coordinates (they're plain text origin/dest);
   * the dispatcher attaches a Lane and/or assigns a Vehicle in the
   * Tasks/Jobs stage, so by the time we're computing this we have:
   *
   *   1. A `Lane` with `distanceKm` already measured (preferred — these
   *      are real road distances configured by the planner)
   *   2. Or explicit TripStop rows with lat/lng coordinates (used when
   *      a dispatcher built a custom multi-stop route)
   *   3. Or a vehicle currently at known GPS coordinates + a single
   *      destination — we haversine from current position to dest
   *
   * Returns null only if NONE of the above are available (rare — would
   * mean a trip has no lane, no stops, and no vehicle at all).
   *
   * The 60 km/h average matches the route optimizer's estimateDuration.
   */
  private async resolveTripRoute(
    tx: any,
    tripId: string,
  ): Promise<{ totalDistanceKm: number; totalDurationMin: number; laneId?: string } | null> {
    const trip = await tx.trip.findUnique({
      where: { id: tripId },
      select: {
        organizationId: true,
        laneId: true,
        lane: { select: { distanceKm: true } },
        stops: {
          orderBy: { sequence: 'asc' },
          select: { id: true, lat: true, lng: true, stopType: true, address: true },
        },
        orders: {
          take: 1,
          orderBy: { createdAt: 'asc' },
          select: { originCity: true, destCity: true },
        },
      },
    });
    if (!trip) return null;

    // Path 1: lane already carries the measured distance.
    if (trip.lane?.distanceKm && trip.lane.distanceKm > 0) {
      const km = trip.lane.distanceKm;
      return {
        totalDistanceKm: Math.round(km * 10) / 10,
        totalDurationMin: Math.round(km), // 60 km/h ⇒ 1 km ≈ 1 min
      };
    }

    // Path 2: explicit TripStop rows with coordinates — used when a
    // dispatcher built a custom multi-stop route by hand.
    const stops = (trip.stops || [])
      .filter((s: any) => s.lat != null && s.lng != null)
      .map((s: any) => ({
        id: s.id,
        lat: s.lat,
        lng: s.lng,
        type: s.stopType,
        address: s.address || '',
      }));

    if (stops.length >= 2) {
      const optimized = this.routeOptimizer.optimizeRoute(stops);
      return {
        totalDistanceKm: optimized.totalDistanceKm,
        totalDurationMin: optimized.estimatedDurationMin,
      };
    }

    // Path 3: auto-match a lane by city names. Try dedicated city
    // fields first, then extract city from address text.
    const order = trip.orders?.[0];
    const originCity = order?.originCity || this.extractCity(order?.originAddress);
    const destCity = order?.destCity || this.extractCity(order?.destAddress);

    if (originCity && destCity) {
      // Try matching against all active lanes — use both exact fields and address text
      const lanes = await tx.lane.findMany({
        where: { organizationId: trip.organizationId, isActive: true },
        select: { id: true, name: true, originName: true, destName: true, distanceKm: true },
      });

      for (const lane of lanes) {
        if (!lane.distanceKm || lane.distanceKm <= 0) continue;
        const lo = (lane.originName || '').toLowerCase();
        const ld = (lane.destName || '').toLowerCase();
        const oc = originCity.toLowerCase();
        const dc = destCity.toLowerCase();
        // Forward or reverse match
        if ((lo.includes(oc) && ld.includes(dc)) || (lo.includes(dc) && ld.includes(oc))) {
          return {
            totalDistanceKm: Math.round(lane.distanceKm * 10) / 10,
            totalDurationMin: Math.round(lane.distanceKm), // 60 km/h
            laneId: lane.id,
          };
        }
      }
    }

    return null;
  }

  /**
   * Extract a likely city name from a free-text address like
   * "Nairobi Warehouse" → "Nairobi", "Embu Distributor" → "Embu".
   * Matches against known Kenyan city names.
   */
  private extractCity(address: string | null | undefined): string | null {
    if (!address) return null;
    const cities = [
      'Nairobi', 'Mombasa', 'Kisumu', 'Nakuru', 'Eldoret', 'Thika',
      'Nyeri', 'Nanyuki', 'Machakos', 'Kitale', 'Malindi', 'Lamu',
      'Garissa', 'Meru', 'Embu', 'Kakamega', 'Bungoma', 'Kericho',
      'Naivasha', 'Nandi', 'Kilifi', 'Voi', 'Athi River', 'Ruiru',
      'Kiambu', 'Kajiado', 'Migori', 'Homa Bay', 'Busia', 'Lodwar',
      'Marsabit', 'Isiolo', 'Moyale', 'Wajir', 'Mandera', 'Muranga',
      'Kerugoya', 'Sagana', 'Kangundo', 'Limuru', 'Githunguri',
    ];
    const lower = address.toLowerCase();
    for (const city of cities) {
      if (lower.includes(city.toLowerCase())) return city;
    }
    // Fallback: take the first word (often the city in "City Warehouse" pattern)
    const first = address.split(/[\s,]+/)[0];
    return first && first.length >= 3 ? first : null;
  }

  /**
   * Create a new trip with full pre-flight validation.
   *
   * Wrapped in a `Serializable` transaction to prevent two dispatchers
   * from simultaneously assigning the same driver or vehicle. Validates:
   *   - Driver exists, is active, and not on another active trip
   *   - Vehicle exists, is not on another active trip, and driver matches
   *   - All linked orders belong to this org and are not already on a trip
   *   - Total order weight does not exceed vehicle max payload
   *
   * Route distance/duration is resolved eagerly if enough data is available
   * (lane or explicit stops). Otherwise, resolution is deferred to dispatch.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param dto - Trip creation payload (vehicle, driver, orders, stops, lane, etc.).
   * @returns The created trip with stops, vehicle, and driver relations.
   * @throws {BadRequestException} No route surface, capacity exceeded, or resource conflicts.
   * @throws {NotFoundException} Vehicle, driver, or orders not found.
   */
  async create(organizationId: string, dto: CreateTripDto) {
    // A trip needs a route surface — either a Lane (most common, the
    // planner picks one in the Tasks/Jobs stage), or explicit Stops
    // (manual multi-stop route), or at least one linked Order with
    // an origin/destination text we can show. Without any of those
    // there's nothing to render in the Gantt and ETA can't be computed.
    if (!dto.laneId && (!dto.stops || dto.stops.length === 0) && (!dto.orderIds || dto.orderIds.length === 0)) {
      throw new BadRequestException(
        'A trip must have at least one of: laneId, stops, or orderIds. Pick a lane in Tasks/Jobs first.',
      );
    }

    // Wrap the entire pre-flight validation + trip create + order
    // linking in a single Postgres transaction so that two dispatchers
    // hitting "Create" simultaneously can't both pass the
    // driver/vehicle availability checks.
    return this.prisma.$transaction(async (tx) => {
      // Set RLS tenant context inside the transaction
      await tx.$queryRaw`SELECT set_config('app.current_org_id', ${organizationId}, true)`;

      // ── Driver availability + isActive + vehicle-lock checks ────────
      if (dto.driverId) {
        // Driver must belong to this org AND be active
        const driver = await tx.driver.findFirst({
          where: { id: dto.driverId, organizationId },
          select: { id: true, isActive: true, firstName: true, lastName: true },
        });
        if (!driver) {
          throw new NotFoundException(
            `Driver not found (id: ${dto.driverId}). Ensure the driver exists and belongs to your organization.`,
          );
        }
        if (!driver.isActive) {
          throw new BadRequestException(
            `${driver.firstName} ${driver.lastName} is deactivated and cannot be assigned to a trip.`,
          );
        }

        // Block if driver is already on an active trip
        const activeTrip = await tx.trip.findFirst({
          where: {
            organizationId,
            driverId: dto.driverId,
            status: { in: ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP'] },
          },
          select: { tripNumber: true },
        });
        if (activeTrip) {
          throw new BadRequestException(
            `Driver is currently on an active trip (${activeTrip.tripNumber}). They must complete that trip before being assigned to another.`,
          );
        }

        // Block if vehicle has a different driver assigned
        if (dto.vehicleId) {
          const vehicle = await tx.vehicle.findFirst({
            where: { id: dto.vehicleId, organizationId },
            select: { driverId: true },
          });
          if (vehicle?.driverId && vehicle.driverId !== dto.driverId) {
            throw new BadRequestException(
              'This vehicle has a different driver assigned. Update the driver assignment in the Vehicles tab first.',
            );
          }
        }
      }

      // Vehicle must belong to this org
      let vehicleRow: { id: string; driverId: string | null; maxWeight: number | null } | null = null;
      if (dto.vehicleId) {
        vehicleRow = await tx.vehicle.findFirst({
          where: { id: dto.vehicleId, organizationId },
          select: { id: true, driverId: true, maxWeight: true },
        });
        if (!vehicleRow) {
          throw new NotFoundException('Vehicle not found');
        }
        // Race-condition guard: another dispatcher may have just put this
        // vehicle on a different active trip.
        const inFlight = await tx.trip.findFirst({
          where: {
            organizationId,
            vehicleId: dto.vehicleId,
            status: { in: ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP'] },
          },
          select: { tripNumber: true },
        });
        if (inFlight) {
          throw new BadRequestException(
            `Vehicle is already on an active trip (${inFlight.tripNumber}). Complete or reassign before creating a new trip.`,
          );
        }
      }

      // Auto-assign vehicle's driver if none specified
      let resolvedDriverId = dto.driverId;
      if (!resolvedDriverId && vehicleRow?.driverId) {
        resolvedDriverId = vehicleRow.driverId;
      }

      // ── Order validation: must belong to org, weight must fit ───────
      if (dto.orderIds && dto.orderIds.length > 0) {
        const orders = await tx.order.findMany({
          where: { id: { in: dto.orderIds }, organizationId },
          select: { id: true, weight: true, status: true, tripId: true },
        });
        if (orders.length !== dto.orderIds.length) {
          throw new BadRequestException(
            'One or more orders do not exist in this organization.',
          );
        }
        // Refuse to link orders that are already on another trip
        const alreadyLinked = orders.filter((o) => o.tripId);
        if (alreadyLinked.length > 0) {
          throw new BadRequestException(
            `${alreadyLinked.length} order(s) are already on another trip. Unlink them first.`,
          );
        }
        // Capacity check
        if (vehicleRow?.maxWeight) {
          const totalWeight = orders.reduce((s, o) => s + (o.weight || 0), 0);
          if (totalWeight > vehicleRow.maxWeight) {
            throw new BadRequestException(
              `Total order weight (${totalWeight}kg) exceeds vehicle max payload (${vehicleRow.maxWeight}kg).`,
            );
          }
        }
      }

      const tripNumber = await this.generateTripNumber(organizationId, tx);

      const trip = await tx.trip.create({
        data: {
          organizationId,
          tripNumber,
          vehicleId: dto.vehicleId,
          driverId: resolvedDriverId,
          startDate: dto.startDate ? new Date(dto.startDate) : undefined,
          endDate: dto.endDate ? new Date(dto.endDate) : undefined,
          loadingBayId: dto.loadingBayId,
          laneId: dto.laneId,
          notes: dto.notes,
          podRequired: dto.podRequired ?? false,
          status: 'PLANNED',
          stops: dto.stops
            ? {
                create: dto.stops.map((s) => ({
                  sequence: s.sequence,
                  stopType: s.stopType,
                  name: s.name,
                  address: s.address,
                  city: s.city,
                  state: s.state,
                  zipCode: s.zipCode,
                  lat: s.lat,
                  lng: s.lng,
                  plannedArrival: s.plannedArrival
                    ? new Date(s.plannedArrival)
                    : undefined,
                  plannedDeparture: s.plannedDeparture
                    ? new Date(s.plannedDeparture)
                    : undefined,
                  notes: s.notes,
                })),
              }
            : undefined,
        },
        include: { stops: { orderBy: { sequence: 'asc' } }, vehicle: true, driver: true },
      });

      // Link orders to this trip
      if (dto.orderIds && dto.orderIds.length > 0) {
        await tx.order.updateMany({
          where: { id: { in: dto.orderIds }, organizationId },
          data: { tripId: trip.id, status: 'ASSIGNED', jobStatus: 'ALLOCATED' },
        });
      }

      // Try to resolve route distance + duration NOW if the trip
      // already has a lane or explicit stops. If not (common — at
      // create time the dispatcher might still be picking a lane),
      // we leave the columns null and recompute when the trip moves
      // to DISPATCHED.
      try {
        const route = await this.resolveTripRoute(tx, trip.id);
        if (route) {
          const updated = await tx.trip.update({
            where: { id: trip.id },
            data: {
              totalDistance: route.totalDistanceKm,
              totalDuration: route.totalDurationMin,
            },
            include: { stops: { orderBy: { sequence: 'asc' } }, vehicle: true, driver: true },
          });
          return updated;
        }
      } catch (err: any) {
        this.logger.warn(
          `Trip ${trip.tripNumber}: route resolve failed at create: ${err?.message || err}`,
        );
      }

      return trip;
    }, { isolationLevel: 'Serializable' });
  }

  /**
   * List trips with pagination, enriched with bay deadline information.
   *
   * When `driverUserId` is provided (DRIVER role), results are filtered
   * to only show trips assigned to that driver. The driver is matched
   * by email or name against the `drivers` table.
   *
   * Each trip with a loading bay and dispatch time is enriched with a
   * `bayDeadline` ISO timestamp computed from the org's `bayTimeout`
   * settings, so the driver app can show countdown timers.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param params - Pagination and sorting parameters.
   * @param driverUserId - Optional user ID to filter by driver (for DRIVER role).
   * @returns Paginated result with enriched trip data.
   */
  async findAll(organizationId: string, params: PaginationParams, driverUserId?: string) {
    const { skip, take, orderBy, page, limit } = buildPaginationQuery(params);

    // If driverUserId is set (DRIVER role), only show trips assigned to this driver
    const where: any = { organizationId };
    if (driverUserId) {
      // Find ALL driver records that could match this user (by email or name)
      const user = await this.prisma.user.findUnique({
        where: { id: driverUserId },
        select: { email: true, firstName: true, lastName: true },
      });
      if (user) {
        const matchingDrivers = await this.prisma.driver.findMany({
          where: {
            organizationId,
            OR: [
              ...(user.email ? [{ email: user.email }] : []),
              { firstName: user.firstName || '', lastName: user.lastName || '' },
            ],
          },
          select: { id: true },
        });
        const driverIds = matchingDrivers.map(d => d.id);
        if (driverIds.length > 0) {
          where.driverId = { in: driverIds };
        } else {
          where.driverId = 'no-match-00000000';
        }
      } else {
        where.driverId = 'no-match-00000000';
      }
    }

    const [items, total] = await Promise.all([
      this.prisma.trip.findMany({
        where,
        skip,
        take,
        orderBy,
        include: {
          vehicle: { select: { id: true, unitNumber: true, licensePlate: true, type: true, status: true, maxWeight: true, 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, phone: true },
          },
          orders: {
            select: { id: true, orderNumber: true, status: true, jobStatus: true, originAddress: true, destAddress: true, commodity: true, weight: true, loadingConfirmed: true, specialInstructions: true, client: { select: { name: true } } },
          },
          loadingBay: { select: { id: true, name: true, code: true, category: true, latitude: true, longitude: true } },
          stops: true,
          _count: { select: { stops: true, orders: true } },
        },
      }),
      this.prisma.trip.count({ where }),
    ]);

    // Enrich with bayDeadline (same logic as findOne) so driver app
    // home screen can show bay strip with deadline
    let maxWait: number | null = null;
    const enriched = items.map((t: any) => {
      if (!t.loadingBayId || !t.dispatchedAt) return { ...t, bayDeadline: null, bayMaxWaitMinutes: null };
      // Lazy-load org settings once
      return { ...t, _needsBayDeadline: true };
    });

    const needsDeadline = enriched.some((t: any) => t._needsBayDeadline);
    if (needsDeadline && organizationId) {
      const org = await this.prisma.organization.findUnique({
        where: { id: organizationId },
        select: { settings: true },
      });
      const settings = (org?.settings as Record<string, any>) || {};
      maxWait = settings.bayTimeout?.maxWaitMinutes ?? 120;
    }

    const data = enriched.map((t: any) => {
      if (!t._needsBayDeadline) return t;
      const { _needsBayDeadline, ...rest } = t;
      const deadline = new Date(
        new Date(rest.dispatchedAt).getTime() + (maxWait ?? 120) * 60_000,
      ).toISOString();
      return { ...rest, bayDeadline: deadline, bayMaxWaitMinutes: maxWait };
    });

    return { data, meta: buildPaginationMeta(total, page, limit) };
  }

  /** Org-scoped lookup. Cross-tenant ID-guessing impossible. */
  async findOne(organizationId: string, id: string) {
    const trip = await this.prisma.trip.findFirst({
      where: { id, organizationId },
      include: {
        vehicle: {
          include: {
            vehicleClass: { select: { id: true, name: true, code: true, tonnage: true } },
            transporter: { select: { id: true, name: true, code: true } },
          },
        },
        driver: true,
        loadingBay: { select: { id: true, name: true, code: true, category: true, latitude: true, longitude: true } },
        lane: { select: { id: true, name: true, originName: true, destName: true, distanceKm: true, estimatedHours: true } },
        stops: { orderBy: { sequence: 'asc' } },
        orders: {
          include: {
            client: { select: { id: true, name: true, code: true } },
          },
        },
      },
    });
    if (!trip) throw new NotFoundException('Trip not found');

    // Enrich with bay deadline computed from org settings. The driver
    // app uses this to show "BE AT BAY BY <deadline>" and turns red
    // when the deadline has passed. Without this the app was showing
    // a hardcoded time from trip.startDate which has nothing to do
    // with the bay timeout policy.
    let bayDeadline: string | null = null;
    let bayMaxWaitMinutes: number | null = null;
    if (trip.loadingBayId && trip.dispatchedAt) {
      const org = await this.prisma.organization.findUnique({
        where: { id: organizationId },
        select: { settings: true },
      });
      const settings = (org?.settings as Record<string, any>) || {};
      const maxWait = settings.bayTimeout?.maxWaitMinutes ?? 120;
      bayMaxWaitMinutes = maxWait;
      bayDeadline = new Date(
        new Date(trip.dispatchedAt).getTime() + maxWait * 60_000,
      ).toISOString();
    }

    return {
      ...trip,
      bayDeadline,
      bayMaxWaitMinutes,
    };
  }

  /**
   * Update a trip's fields including status transitions.
   *
   * Status changes are validated by the trip state machine. Specific
   * transition side-effects:
   *   - `IN_TRANSIT`: requires all linked orders to have loading confirmed,
   *     flips order statuses, releases the dock bay, sets `departedAt`
   *
   * Driver self-service: DRIVER role callers can only update `status` and
   * `notes` on trips assigned to them.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param id - The trip's database ID.
   * @param dto - Partial update payload.
   * @param caller - Optional caller context for driver guard `{ userId, role }`.
   * @returns The updated trip with relations.
   * @throws {BadRequestException} Invalid transition, unloaded orders, or driver permission violation.
   */
  async update(
    organizationId: string,
    id: string,
    dto: UpdateTripDto,
    caller?: { userId: string; role: string },
  ) {
    const existing = await this.findOne(organizationId, id);

    // ── Driver self-service guard ─────────────────────────────────────
    // When a DRIVER calls this endpoint, two extra rules apply:
    //   1. They can only update THEIR OWN trip (by matching the trip's
    //      driverId to a Driver record linked to this user via email).
    //   2. They can only change the `status` field — no vehicle/driver
    //      reassignment or date changes.
    if (caller?.role === 'DRIVER') {
      const allowedKeys = new Set(['status', 'notes']);
      const tooMany = Object.keys(dto).filter(
        (k) => !allowedKeys.has(k) && (dto as any)[k] !== undefined,
      );
      if (tooMany.length > 0) {
        throw new BadRequestException(
          `Drivers can only update status/notes on their own trip. Disallowed fields: ${tooMany.join(', ')}.`,
        );
      }
      // Verify the caller is the assigned driver. Match by email (the
      // driver record's email == the User account's email — same
      // pattern findAll uses for driver-scoped trip listing).
      const user = await this.prisma.user.findUnique({
        where: { id: caller.userId },
        select: { email: true, firstName: true, lastName: true },
      });
      if (!user) throw new NotFoundException('User not found');
      const matching = await this.prisma.driver.findMany({
        where: {
          organizationId,
          OR: [
            ...(user.email ? [{ email: user.email }] : []),
            { firstName: user.firstName || '', lastName: user.lastName || '' },
          ],
        },
        select: { id: true },
      });
      const driverIds = new Set(matching.map((d) => d.id));
      if (!existing.driverId || !driverIds.has(existing.driverId)) {
        throw new BadRequestException(
          'You can only update trips assigned to you.',
        );
      }
    }

    // State machine guard on the status field if it changed
    if (dto.status !== undefined && dto.status !== existing.status) {
      assertTripStatusTransition(existing.status, dto.status);
    }

    // ── Driver availability & vehicle-driver lock checks ──────────────────
    if (dto.driverId !== undefined && dto.driverId !== null && dto.driverId !== existing.driverId) {
      // Driver must belong to org and be active
      const newDriver = await this.prisma.driver.findFirst({
        where: { id: dto.driverId, organizationId },
        select: { isActive: true, firstName: true, lastName: true },
      });
      if (!newDriver) {
        throw new NotFoundException(
          `Driver not found (id: ${dto.driverId}). Ensure the driver exists and belongs to your organization.`,
        );
      }
      if (!newDriver.isActive) {
        throw new BadRequestException(
          `${newDriver.firstName} ${newDriver.lastName} is deactivated and cannot be assigned to a trip.`,
        );
      }

      const activeTrip = await this.prisma.trip.findFirst({
        where: {
          organizationId,
          driverId: dto.driverId,
          status: { in: ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP'] },
          id: { not: id }, // exclude current trip
        },
        select: { tripNumber: true },
      });
      if (activeTrip) {
        throw new BadRequestException(
          `Driver is currently on an active trip (${activeTrip.tripNumber}). They must complete that trip before being reassigned.`,
        );
      }

      const vehicleId = dto.vehicleId ?? existing.vehicleId;
      if (vehicleId) {
        const vehicle = await this.prisma.vehicle.findFirst({
          where: { id: vehicleId, organizationId },
          select: { driverId: true },
        });
        if (vehicle?.driverId && vehicle.driverId !== dto.driverId) {
          throw new BadRequestException(
            'This vehicle has a different driver assigned. Update the driver assignment in the Vehicles tab first.',
          );
        }
      }
    }

    const data: Record<string, unknown> = {};
    if (dto.vehicleId !== undefined) data.vehicleId = dto.vehicleId;
    if (dto.driverId !== undefined) data.driverId = dto.driverId;
    if (dto.laneId !== undefined) data.laneId = dto.laneId;
    if (dto.loadingBayId !== undefined) data.loadingBayId = dto.loadingBayId;
    if (dto.notes !== undefined) data.notes = dto.notes;
    if (dto.status !== undefined) data.status = dto.status;
    if (dto.startDate) data.startDate = new Date(dto.startDate);
    if (dto.endDate) data.endDate = new Date(dto.endDate);

    // Enforce: cannot move to IN_TRANSIT until all linked orders have loading confirmed
    if (dto.status === 'IN_TRANSIT') {
      const unloadedCount = await this.prisma.order.count({
        where: { tripId: id, loadingConfirmed: false },
      });
      if (unloadedCount > 0) {
        throw new BadRequestException(
          `Cannot start trip — ${unloadedCount} order(s) not yet loaded. Confirm loading on each order first.`,
        );
      }
      // Flip linked orders to IN_TRANSIT now that we're actually rolling
      await this.prisma.order.updateMany({
        where: { tripId: id },
        data: { status: 'IN_TRANSIT' },
      });
      // Vehicle is now actually moving
      const t = await this.prisma.trip.findUnique({
        where: { id },
        select: { vehicleId: true },
      });
      if (t?.vehicleId) {
        await this.prisma.vehicle.update({
          where: { id: t.vehicleId },
          data: { status: 'IN_TRANSIT' },
        }).catch(() => {});
      }
    }

    // Release dock bay when trip starts moving (IN_TRANSIT)
    if (dto.status === 'IN_TRANSIT') {
      const tripData = await this.prisma.trip.findUnique({
        where: { id },
        select: { loadingBayId: true, departedAt: true },
      });
      if (tripData?.loadingBayId) {
        await this.prisma.loadingBay.update({
          where: { id: tripData.loadingBayId },
          data: { status: 'AVAILABLE', currentVehicleId: null },
        }).catch(() => {});
        // Record departure event
        await this.prisma.bayEvent.create({
          data: {
            organizationId: (await this.prisma.trip.findUnique({ where: { id }, select: { organizationId: true } }))!.organizationId,
            loadingBayId: tripData.loadingBayId,
            tripId: id,
            eventType: 'DEPARTURE',
            actualAt: new Date(),
            status: 'ON_TIME',
            notes: 'Vehicle departed dock bay — trip started',
          },
        }).catch(() => {});
      }
      // Set departed timestamp
      data.departedAt = new Date();
    }

    return this.prisma.trip.update({
      where: { id },
      data,
      include: {
        vehicle: true,
        driver: true,
        stops: { orderBy: { sequence: 'asc' } },
      },
    });
  }

  /**
   * Dispatch a planned trip — the key operational transition.
   *
   * Dispatch is the moment the trip becomes "live". This method:
   *   1. Validates the trip is in PLANNED status with a vehicle assigned
   *   2. Resolves and persists route distance + duration (requires a lane
   *      or explicit stops; fails with a clear message if missing)
   *   3. Sets status to DISPATCHED with `dispatchedAt` timestamp
   *   4. Marks the vehicle as ASSIGNED (waiting at dock)
   *   5. Updates linked orders to ASSIGNED / DISPATCHED job status
   *   6. Auto-assigns a loading bay if none is set (e.g., after timeout reallocation)
   *   7. Marks the assigned bay as OCCUPIED
   *   8. Sends an FCM push notification to the assigned driver
   *
   * @param organizationId - The tenant's organisation ID.
   * @param id - The trip's database ID.
   * @returns The dispatched trip with relations.
   * @throws {BadRequestException} Trip not in PLANNED status, no vehicle, or no route.
   */
  async dispatch(organizationId: string, id: string) {
    const trip = await this.findOne(organizationId, id);

    if (trip.status !== 'PLANNED') {
      throw new BadRequestException(
        `Cannot dispatch trip in ${trip.status} status`,
      );
    }

    if (!trip.vehicleId) {
      throw new BadRequestException(
        'Trip must have a vehicle assigned before dispatch',
      );
    }

    // ── Compute route distance + duration at dispatch time ─────────
    // By this point in the workflow the planner has attached a lane
    // (or built explicit stops) and the dispatcher has assigned a
    // vehicle, so we have everything we need to lock in the route.
    // We persist totalDistance + totalDuration on the trip itself so
    // the dispatch Gantt, predictive ETA, and trip detail page all
    // show real numbers instead of "0 km / 0 min".
    //
    // resolveTripRoute can also auto-match a lane by city pair if the
    // planner forgot to attach one — in that case it returns laneId
    // and we stamp it on the trip so the ETA service and tariff
    // lookup both find it next time.
    let routePatch: { totalDistance?: number; totalDuration?: number; laneId?: string } = {};
    try {
      const route = await this.resolveTripRoute(this.prisma, id);
      if (route) {
        routePatch = {
          totalDistance: route.totalDistanceKm,
          totalDuration: route.totalDurationMin,
          ...(route.laneId ? { laneId: route.laneId } : {}),
        };
      } else {
        throw new BadRequestException(
          `Cannot dispatch — no route found for trip ${trip.tripNumber}. ` +
          `Please assign a Route (lane) to this trip in the Tasks board before dispatching. ` +
          `Go to Routes & Rates to create the lane if it doesn't exist.`,
        );
      }
    } catch (err: any) {
      if (err instanceof BadRequestException) throw err;
      this.logger.warn(
        `Trip ${trip.tripNumber}: route resolve failed at dispatch: ${err?.message || err}`,
      );
      throw new BadRequestException(
        `Cannot dispatch — route computation failed for trip ${trip.tripNumber}. ` +
        `Please assign a Route (lane) manually before dispatching.`,
      );
    }

    // Update trip status (and stamp distance/duration if we resolved them)
    const updated = await this.prisma.trip.update({
      where: { id },
      data: {
        status: 'DISPATCHED',
        dispatchedAt: new Date(),
        ...routePatch,
      },
      include: { vehicle: true, driver: true, stops: true },
    });

    // Update vehicle status to ASSIGNED (waiting at dock for loading)
    await this.prisma.vehicle.update({
      where: { id: trip.vehicleId },
      data: { status: 'ASSIGNED' },
    });

    // Linked orders stay ASSIGNED until driver confirms loading on each
    // (do NOT flip to IN_TRANSIT here — that breaks the loading-confirmation flow)
    await this.prisma.order.updateMany({
      where: { tripId: id },
      data: { status: 'ASSIGNED', jobStatus: 'DISPATCHED' },
    });

    // ── Auto-assign a loading bay if none is set (e.g. after bay
    // timeout reallocation clears loadingBayId) ────────────────────
    let bayId = trip.loadingBayId;
    if (!bayId) {
      bayId = await this.findBestAvailableBay(organizationId, trip);
      if (bayId) {
        await this.prisma.trip.update({
          where: { id },
          data: { loadingBayId: bayId },
        });
      }
    }

    // Mark the assigned loading bay as OCCUPIED immediately on dispatch
    if (bayId) {
      await this.prisma.loadingBay.update({
        where: { id: bayId },
        data: {
          status: 'OCCUPIED',
          currentVehicleId: trip.vehicleId,
        },
      }).catch(() => {});
    }

    // Send push notification to the driver
    if (trip.driverId) {
      this.pushService.sendToDriver(
        trip.driverId,
        trip.organizationId,
        'Trip Dispatched!',
        `Trip ${trip.tripNumber} has been dispatched. Tap to start.`,
        { tripId: trip.id },
      ).catch(() => {}); // Don't block on push failure
    }

    return updated;
  }

  /**
   * Find the best available loading bay for a trip using the same
   * scoring logic as ingestion auto-allocator: site match, category
   * match, shift capacity, load balancing.
   */
  private async findBestAvailableBay(organizationId: string, trip: any): Promise<string | null> {
    const availableBays = await this.prisma.loadingBay.findMany({
      where: { organizationId, status: 'AVAILABLE', isActive: true },
    });
    if (availableBays.length === 0) return null;

    // Determine desired bay category from order commodity
    const firstOrder = trip.orders?.[0];
    let desiredCategory: string | null = null;
    if (firstOrder?.commodity) {
      const c = firstOrder.commodity.toLowerCase();
      if (c.includes('keg')) desiredCategory = 'KEG';
    }

    // Count trips per bay today for load balancing
    const todayStart = new Date();
    todayStart.setHours(0, 0, 0, 0);
    const todayEnd = new Date();
    todayEnd.setHours(23, 59, 59, 999);

    const todayTripCounts = await this.prisma.trip.groupBy({
      by: ['loadingBayId'],
      where: {
        organizationId,
        loadingBayId: { in: availableBays.map(b => b.id), not: null },
        createdAt: { gte: todayStart, lte: todayEnd },
      },
      _count: { id: true },
    });
    const bayTripCountMap = new Map(
      todayTripCounts.map(r => [r.loadingBayId as string, r._count.id]),
    );

    // Score each bay
    const scoredBays = availableBays
      .filter(bay => {
        const tripCount = bayTripCountMap.get(bay.id) ?? 0;
        if (bay.shiftCapacity && tripCount >= bay.shiftCapacity) return false;
        return true;
      })
      .map(bay => {
        let score = 0;
        if (firstOrder?.plantName && bay.siteName &&
            bay.siteName.toLowerCase().includes(firstOrder.plantName.toLowerCase())) {
          score += 40;
        }
        if (desiredCategory) {
          if (bay.category === desiredCategory) score += 30;
          else if (bay.category === 'MIXED') score += 10;
        } else {
          score += 5;
        }
        const tripCount = bayTripCountMap.get(bay.id) ?? 0;
        score -= tripCount * 5;
        if (bay.shiftCapacity) score += Math.min(bay.shiftCapacity, 10);
        return { bay, score };
      })
      .sort((a, b) => b.score - a.score);

    return scoredBays.length > 0 ? scoredBays[0].bay.id : null;
  }

  /**
   * Mark a trip as completed.
   *
   * Validates that POD (Proof of Delivery) has been submitted if required.
   * On completion: sets `endDate`, releases the vehicle to AVAILABLE,
   * releases the bay, and marks all linked orders as DELIVERED with
   * `actualDelivery` timestamp.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param id - The trip's database ID.
   * @returns The completed trip.
   * @throws {BadRequestException} Trip not in a completable status, or POD required but not submitted.
   */
  async complete(organizationId: string, id: string) {
    const trip = await this.findOne(organizationId, id);

    if (!['DISPATCHED', 'IN_TRANSIT', 'AT_STOP'].includes(trip.status)) {
      throw new BadRequestException(
        `Cannot complete trip in ${trip.status} status`,
      );
    }

    // Block completion if POD is required but not submitted
    if (trip.podRequired && !trip.podSubmitted) {
      throw new BadRequestException(
        'Proof of delivery is required for this trip. Submit POD before completing.',
      );
    }

    const updated = await this.prisma.trip.update({
      where: { id },
      data: { status: 'COMPLETED', endDate: new Date() },
      include: { vehicle: true, driver: true, stops: true },
    });

    // Free up the vehicle
    if (trip.vehicleId) {
      await this.prisma.vehicle.update({
        where: { id: trip.vehicleId },
        data: { status: 'AVAILABLE' },
      });
    }

    // Release the bay if it's still occupied by this trip
    if (trip.loadingBayId) {
      await this.prisma.loadingBay.update({
        where: { id: trip.loadingBayId },
        data: { status: 'AVAILABLE', currentVehicleId: null },
      }).catch(() => {});
    }

    // Mark orders as delivered
    await this.prisma.order.updateMany({
      where: { tripId: id },
      data: { status: 'DELIVERED', actualDelivery: new Date() },
    });

    return updated;
  }

  /**
   * Cancel a trip and release all associated resources.
   *
   * Resets the vehicle to AVAILABLE, releases the bay, and unlinks all
   * orders back to VALIDATED / PENDING status so they can be reassigned.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param id - The trip's database ID.
   * @returns `{ cancelled: true }` on success.
   * @throws {BadRequestException} Trip is already COMPLETED or CANCELLED.
   */
  async cancel(organizationId: string, id: string) {
    const trip = await this.findOne(organizationId, id);
    if (['COMPLETED', 'CANCELLED'].includes(trip.status)) {
      throw new BadRequestException(`Trip is already ${trip.status}`);
    }
    await this.prisma.trip.update({ where: { id }, data: { status: 'CANCELLED' } });
    // Reset vehicle to AVAILABLE
    if (trip.vehicleId) {
      await this.prisma.vehicle.update({ where: { id: trip.vehicleId }, data: { status: 'AVAILABLE' } });
    }
    // Release bay if occupied
    if (trip.loadingBayId) {
      await this.prisma.loadingBay.update({ where: { id: trip.loadingBayId }, data: { status: 'AVAILABLE', currentVehicleId: null } }).catch(() => {});
    }
    // Unlink orders back to PENDING
    await this.prisma.order.updateMany({
      where: { tripId: id },
      data: { tripId: null, status: 'VALIDATED', jobStatus: 'PENDING' },
    });
    return { cancelled: true };
  }

  /**
   * Delete a trip and clean up all associated resources.
   *
   * Resets the vehicle (if PLANNED/ASSIGNED), releases the bay, unlinks
   * all orders back to the Jobs board, then deletes the trip record.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param id - The trip's database ID.
   * @returns `{ deleted: true }` on success.
   */
  async remove(organizationId: string, id: string) {
    const trip = await this.findOne(organizationId, id);
    // Reset vehicle if it was ASSIGNED to this trip
    if (trip.vehicleId && ['PLANNED', 'ASSIGNED'].includes(trip.status)) {
      // Only reset if no other active trips for this vehicle
      const otherActiveTrips = await this.prisma.trip.count({
        where: { vehicleId: trip.vehicleId, id: { not: id }, status: { in: ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP'] } },
      });
      if (otherActiveTrips === 0) {
        await this.prisma.vehicle.update({ where: { id: trip.vehicleId }, data: { status: 'AVAILABLE' } });
      }
    }
    // Release bay
    if (trip.loadingBayId) {
      await this.prisma.loadingBay.update({ where: { id: trip.loadingBayId }, data: { status: 'AVAILABLE', currentVehicleId: null } }).catch(() => {});
    }
    // Unlink orders first
    await this.prisma.order.updateMany({
      where: { tripId: id },
      data: { tripId: null, status: 'VALIDATED', jobStatus: 'PENDING' },
    });
    await this.prisma.trip.delete({ where: { id } });
    return { deleted: true };
  }

  private async generateTripNumber(
    organizationId: string,
    tx?: { trip: { count: (args: any) => Promise<number> } },
  ): Promise<string> {
    const client = tx ?? this.prisma;
    const count = await client.trip.count({ where: { organizationId } });
    const num = count + 1;
    return `TRP-${String(num).padStart(6, '0')}`;
  }
}

results matching ""

    No results matching ""