src/trips/trips.service.ts
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.
Methods |
constructor(prisma: PrismaService, pushService: PushService, routeOptimizer: RouteOptimizer)
|
||||||||||||
|
Defined in src/trips/trips.service.ts:66
|
||||||||||||
|
Parameters :
|
| Async cancel | ||||||||||||
cancel(organizationId: string, id: string)
|
||||||||||||
|
Defined in src/trips/trips.service.ts:1051
|
||||||||||||
|
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 :
Returns :
unknown
|
| Async complete | ||||||||||||
complete(organizationId: string, id: string)
|
||||||||||||
|
Defined in src/trips/trips.service.ts:993
|
||||||||||||
|
Mark a trip as completed. Validates that POD (Proof of Delivery) has been submitted if required.
On completion: sets
Parameters :
Returns :
unknown
The completed trip. |
| Async create | ||||||||||||
create(organizationId: string, dto: CreateTripDto)
|
||||||||||||
|
Defined in src/trips/trips.service.ts:222
|
||||||||||||
|
Create a new trip with full pre-flight validation. Wrapped in a
Route distance/duration is resolved eagerly if enough data is available (lane or explicit stops). Otherwise, resolution is deferred to dispatch.
Parameters :
Returns :
unknown
The created trip with stops, vehicle, and driver relations. |
| Async dispatch | ||||||||||||
dispatch(organizationId: string, id: string)
|
||||||||||||
|
Defined in src/trips/trips.service.ts:797
|
||||||||||||
|
Dispatch a planned trip — the key operational transition. Dispatch is the moment the trip becomes "live". This method:
Parameters :
Returns :
unknown
The dispatched trip with relations. |
| Async findAll | ||||||||||||||||
findAll(organizationId: string, params: PaginationParams, driverUserId?: string)
|
||||||||||||||||
|
Defined in src/trips/trips.service.ts:444
|
||||||||||||||||
|
List trips with pagination, enriched with bay deadline information. When Each trip with a loading bay and dispatch time is enriched with a
Parameters :
Returns :
unknown
Paginated result with enriched trip data. |
| Async findOne |
findOne(organizationId: string, id: string)
|
|
Defined in src/trips/trips.service.ts:531
|
|
Org-scoped lookup. Cross-tenant ID-guessing impossible.
Returns :
unknown
|
| Async remove | ||||||||||||
remove(organizationId: string, id: string)
|
||||||||||||
|
Defined in src/trips/trips.service.ts:1083
|
||||||||||||
|
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 :
Returns :
unknown
|
| Async update | ||||||||||||||||||||
update(organizationId: string, id: string, dto: UpdateTripDto, caller?: literal type)
|
||||||||||||||||||||
|
Defined in src/trips/trips.service.ts:599
|
||||||||||||||||||||
|
Update a trip's fields including status transitions. Status changes are validated by the trip state machine. Specific transition side-effects:
Driver self-service: DRIVER role callers can only update
Parameters :
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')}`;
}
}