src/loading-bays/bay-timeout-worker.service.ts
Background worker that enforces loading bay time limits.
Ticks every 60 seconds and for each org:
This prevents bays from being held infinitely when:
Methods |
constructor(prisma: PrismaService, pushService: PushService)
|
|||||||||
|
Parameters :
|
| 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 },
);
}
}