File

src/loading-bays/bay-timeout-worker.service.ts

Description

Background worker that enforces loading bay time limits.

Ticks every 60 seconds and for each org:

  1. Loads the org's bayTimeout config (defaults: 120 min wait, 60 min grace, alertOnLate=true)
  2. Finds all OCCUPIED bays
  3. For each occupied bay, computes how long it's been occupied (from the trip's dispatchedAt timestamp — that's when the bay was reserved)
  4. If occupied > maxWaitMinutes → creates a LATE alert (once)
  5. If occupied > maxWaitMinutes + autoReleaseMinutes → releases the bay (AVAILABLE, clears currentVehicleId)

This prevents bays from being held infinitely when:

  • A trip is dispatched but the driver never shows up
  • Loading takes far longer than expected
  • A trip is abandoned / cancelled but the bay wasn't manually freed

Index

Methods

Constructor

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

Methods

onModuleDestroy
onModuleDestroy()
Returns : void
onModuleInit
onModuleInit()
Returns : void
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PushService } from '../notifications/push.service';

/**
 * Background worker that enforces loading bay time limits.
 *
 * Ticks every 60 seconds and for each org:
 *   1. Loads the org's bayTimeout config (defaults: 120 min wait,
 *      60 min grace, alertOnLate=true)
 *   2. Finds all OCCUPIED bays
 *   3. For each occupied bay, computes how long it's been occupied
 *      (from the trip's dispatchedAt timestamp — that's when the bay
 *      was reserved)
 *   4. If occupied > maxWaitMinutes → creates a LATE alert (once)
 *   5. If occupied > maxWaitMinutes + autoReleaseMinutes → releases
 *      the bay (AVAILABLE, clears currentVehicleId)
 *
 * This prevents bays from being held infinitely when:
 *   - A trip is dispatched but the driver never shows up
 *   - Loading takes far longer than expected
 *   - A trip is abandoned / cancelled but the bay wasn't manually freed
 */
@Injectable()
export class BayTimeoutWorkerService implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(BayTimeoutWorkerService.name);
  private intervalId: NodeJS.Timeout | null = null;
  private running = false;

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

  onModuleInit() {
    this.logger.log('Bay timeout worker starting (tick=60s)');
    this.intervalId = setInterval(() => this.tick(), 60_000);
  }

  onModuleDestroy() {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }

  private async tick() {
    if (this.running) return;
    this.running = true;
    try {
      // organizations is RLS-excluded, safe without tenant context
      const orgs = await this.prisma.organization.findMany({
        select: { id: true, name: true, settings: true },
      });

      for (const org of orgs) {
        try {
          await this.processOrg(org.id, org.name, org.settings as any);
        } catch (err: any) {
          this.logger.error(`Bay timeout: org ${org.name} failed: ${err?.message || err}`);
        }
      }
    } catch (err: any) {
      this.logger.error(`Bay timeout tick failed: ${err?.message || err}`);
    } finally {
      this.running = false;
    }
  }

  /**
   * Process a single organisation's occupied bays for timeout enforcement.
   *
   * For each occupied bay, computes the occupancy duration from the trip's
   * `dispatchedAt` timestamp and applies the two-phase timeout policy:
   *   1. Late warning at `maxWaitMinutes`
   *   2. Auto-release at `maxWaitMinutes + autoReleaseMinutes`
   *
   * All mutations run inside a single Prisma transaction with RLS context
   * set, so bay state changes are atomic and tenant-isolated.
   *
   * @param orgId - The organisation's database ID.
   * @param orgName - The organisation's display name (for logging).
   * @param settings - The organisation's settings JSON (contains `bayTimeout` config).
   */
  private async processOrg(orgId: string, orgName: string, settings: any) {
    const cfg = settings?.bayTimeout || {};
    const maxWaitMinutes = cfg.maxWaitMinutes ?? 120;       // 2 hours default
    const autoReleaseMinutes = cfg.autoReleaseMinutes ?? 60; // 1 hour grace
    const alertOnLate = cfg.alertOnLate !== false;            // default true

    // Use a transaction with tenant context for RLS
    await (this.prisma as any).$transaction(
      async (tx: any) => {
        await tx.$queryRaw`SELECT set_config('app.current_org_id', ${orgId}, true)`;

        // Find all OCCUPIED bays
        const occupiedBays = await tx.loadingBay.findMany({
          where: { status: 'OCCUPIED' },
          select: {
            id: true,
            name: true,
            currentVehicleId: true,
            trips: {
              where: { status: { in: ['DISPATCHED', 'IN_TRANSIT', 'AT_STOP'] } },
              orderBy: { dispatchedAt: 'desc' },
              take: 1,
              select: {
                id: true,
                tripNumber: true,
                dispatchedAt: true,
                vehicleId: true,
                driverId: true,
                driver: { select: { id: true, firstName: true, lastName: true } },
              },
            },
          },
        });

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

        const now = new Date();

        for (const bay of occupiedBays) {
          const trip = bay.trips?.[0];
          if (!trip?.dispatchedAt) continue;

          const occupiedMinutes = (now.getTime() - new Date(trip.dispatchedAt).getTime()) / 60_000;
          const isLate = occupiedMinutes > maxWaitMinutes;
          const shouldRelease = occupiedMinutes > (maxWaitMinutes + autoReleaseMinutes);

          if (shouldRelease) {
            // Auto-release the bay
            await tx.loadingBay.update({
              where: { id: bay.id },
              data: { status: 'AVAILABLE', currentVehicleId: null },
            });

            // Record a bay event
            await tx.bayEvent.create({
              data: {
                organizationId: orgId,
                loadingBayId: bay.id,
                vehicleId: trip.vehicleId,
                tripId: trip.id,
                eventType: 'DEPARTURE',
                actualAt: now,
                status: 'LATE',
                notes: `Auto-released after ${Math.round(occupiedMinutes)} min (limit: ${maxWaitMinutes} + ${autoReleaseMinutes} min grace)`,
              },
            });

            // Create an alert
            if (alertOnLate) {
              const driverName = trip.driver
                ? `${trip.driver.firstName || ''} ${trip.driver.lastName || ''}`.trim()
                : 'Unknown driver';
              await tx.alert.create({
                data: {
                  organizationId: orgId,
                  severity: 'CRITICAL',
                  title: `Bay ${bay.name} auto-released — ${trip.tripNumber}`,
                  message: `Loading bay ${bay.name} was occupied for ${Math.round(occupiedMinutes)} minutes (limit: ${maxWaitMinutes} min). ` +
                    `Trip ${trip.tripNumber} (driver: ${driverName}) exceeded the time window. ` +
                    `The bay has been automatically released and is now available for other vehicles.`,
                },
              });
            }

            // ── Revert the trip back to PLANNED so it appears in the
            // Tasks/Jobs board for reallocation. The dispatcher can
            // assign a new driver or the same driver — no blacklisting.
            // The trip's notes are tagged with [REALLOCATION] so the
            // Jobs page can render a red badge.
            const driverName = trip.driver
              ? `${trip.driver.firstName || ''} ${trip.driver.lastName || ''}`.trim()
              : 'Unknown driver';
            const reallocationNote = `[REALLOCATION] Bay ${bay.name} auto-released after ${Math.round(occupiedMinutes)} min. Previous driver: ${driverName}. Trip returned to Tasks for reassignment.`;
            const existingNotes = (await tx.trip.findUnique({ where: { id: trip.id }, select: { notes: true } }))?.notes || '';
            await tx.trip.update({
              where: { id: trip.id },
              data: {
                status: 'PLANNED',
                loadingBayId: null,
                dispatchedAt: null,
                driverId: null,
                vehicleId: null,
                notes: existingNotes
                  ? `${existingNotes}\n${reallocationNote}`
                  : reallocationNote,
              },
            });

            // Free the vehicle so it can be assigned to another trip
            if (trip.vehicleId) {
              await tx.vehicle.update({
                where: { id: trip.vehicleId },
                data: { status: 'AVAILABLE' },
              }).catch(() => {});
            }

            // Revert linked orders back to ALLOCATED so they show in
            // the Tasks board ready for reassignment. Tag each order's
            // specialInstructions with [REALLOCATION] so the Jobs page
            // can render a red badge without extra API calls.
            const affectedOrders = await tx.order.findMany({
              where: { tripId: trip.id },
              select: { id: true, specialInstructions: true },
            });
            for (const o of affectedOrders) {
              const existing = o.specialInstructions || '';
              const tag = existing.includes('[REALLOCATION]')
                ? existing
                : `[REALLOCATION] Bay timeout — returned for reassignment. | ${existing}`.trim();
              await tx.order.update({
                where: { id: o.id },
                data: {
                  status: 'ASSIGNED',
                  jobStatus: 'ALLOCATED',
                  loadingConfirmed: false,
                  loadingConfirmedAt: null,
                  specialInstructions: tag,
                },
              });
            }

            // Push notification to the driver — trip removed from their app
            if (trip.driverId) {
              this.pushService.sendToDriver(
                trip.driverId,
                orgId,
                `Order Reallocated — ${trip.tripNumber}`,
                `Due to bay timeout at ${bay.name} (${Math.round(occupiedMinutes)} min exceeded), trip ${trip.tripNumber} has been removed from your assignments and returned to dispatch for reassignment. No action needed from you.`,
                { type: 'bay_reallocation', tripId: trip.id, action: 'removed' },
              ).catch(() => {});
            }

            this.logger.warn(
              `[${orgName}] Bay ${bay.name} auto-released, trip ${trip.tripNumber} reverted to PLANNED for reallocation`,
            );
          } else if (isLate && alertOnLate) {
            // Check if we already sent a late alert for this trip+bay combo
            // (avoid spamming alerts every 60s)
            const existingAlert = await tx.alert.findFirst({
              where: {
                organizationId: orgId,
                title: { contains: bay.name },
                createdAt: { gt: new Date(now.getTime() - 24 * 60 * 60_000) },
              },
              select: { id: true },
            });

            if (!existingAlert) {
              const driverName = trip.driver
                ? `${trip.driver.firstName || ''} ${trip.driver.lastName || ''}`.trim()
                : 'Unknown driver';
              const remainingMin = Math.round(maxWaitMinutes + autoReleaseMinutes - occupiedMinutes);
              await tx.alert.create({
                data: {
                  organizationId: orgId,
                  severity: 'WARNING',
                  title: `Bay ${bay.name} overdue — ${trip.tripNumber}`,
                  message: `Loading bay ${bay.name} has been occupied for ${Math.round(occupiedMinutes)} minutes (limit: ${maxWaitMinutes} min). ` +
                    `Trip ${trip.tripNumber} (driver: ${driverName}) is overdue. ` +
                    `The bay will be auto-released in ${remainingMin} minutes if not freed manually.`,
                },
              });

              // Push notification to the driver — you're late!
              if (trip.driverId) {
                this.pushService.sendToDriver(
                  trip.driverId,
                  orgId,
                  `You're late at ${bay.name}!`,
                  `You have ${remainingMin} minutes to complete loading or the bay will be released to another vehicle.`,
                  { type: 'bay_late', tripId: trip.id },
                ).catch(() => {});
              }

              this.logger.warn(
                `[${orgName}] Bay ${bay.name} overdue: ${Math.round(occupiedMinutes)} min (trip ${trip.tripNumber}, releasing in ${remainingMin} min)`,
              );
            }
          }
        }
      },
      { timeout: 30_000, maxWait: 15_000 },
    );
  }
}

results matching ""

    No results matching ""