Backend: - Add Prisma soft-delete middleware for automatic deletedAt filtering (#10) - Split 2758-line copilot.service.ts into focused sub-services (#14): - copilot-schedule.service.ts (schedule/event tools) - copilot-reports.service.ts (reporting/analytics tools) - copilot-fleet.service.ts (vehicle/driver tools) - copilot-vip.service.ts (VIP management tools) - copilot.service.ts now thin orchestrator - Remove manual deletedAt: null from 50+ queries Frontend: - Create SortableHeader component for reusable table sorting (#16) - Create useListPage hook for shared search/filter/sort state (#16) - Update VipList, DriverList, EventList to use shared infrastructure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
import { SignalService } from '../signal/signal.service';
|
||
import { EventStatus } from '@prisma/client';
|
||
|
||
/**
|
||
* Automatic event status management service
|
||
* - Transitions SCHEDULED → IN_PROGRESS when startTime arrives
|
||
* - Sends Signal confirmation requests to drivers
|
||
* - Handles driver responses (1=Confirmed, 2=Delayed, 3=Issue)
|
||
* - Transitions IN_PROGRESS → COMPLETED when endTime passes (with grace period)
|
||
*/
|
||
@Injectable()
|
||
export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
||
private readonly logger = new Logger(EventStatusService.name);
|
||
private intervalId: NodeJS.Timeout | null = null;
|
||
private readonly CHECK_INTERVAL = 60 * 1000; // Check every minute
|
||
private readonly COMPLETION_GRACE_PERIOD = 15 * 60 * 1000; // 15 min after endTime before auto-complete
|
||
|
||
constructor(
|
||
private prisma: PrismaService,
|
||
private signalService: SignalService,
|
||
) {}
|
||
|
||
onModuleInit() {
|
||
this.logger.log('Starting event status monitoring...');
|
||
this.startMonitoring();
|
||
}
|
||
|
||
onModuleDestroy() {
|
||
this.stopMonitoring();
|
||
}
|
||
|
||
private startMonitoring() {
|
||
// Run immediately on start
|
||
this.checkAndUpdateStatuses();
|
||
|
||
// Then run every minute
|
||
this.intervalId = setInterval(() => {
|
||
this.checkAndUpdateStatuses();
|
||
}, this.CHECK_INTERVAL);
|
||
}
|
||
|
||
private stopMonitoring() {
|
||
if (this.intervalId) {
|
||
clearInterval(this.intervalId);
|
||
this.intervalId = null;
|
||
this.logger.log('Stopped event status monitoring');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Main check loop - finds events that need status updates
|
||
*/
|
||
private async checkAndUpdateStatuses() {
|
||
try {
|
||
const now = new Date();
|
||
|
||
// 1. Send reminders for upcoming events (20 min and 5 min before)
|
||
await this.sendUpcomingReminders(now);
|
||
|
||
// 2. Find SCHEDULED events that should now be IN_PROGRESS
|
||
await this.transitionToInProgress(now);
|
||
|
||
// 3. Find IN_PROGRESS events that are past their end time (with grace period)
|
||
await this.transitionToCompleted(now);
|
||
|
||
} catch (error) {
|
||
this.logger.error('Error checking event statuses:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send 20-minute and 5-minute reminders to drivers
|
||
*/
|
||
private async sendUpcomingReminders(now: Date) {
|
||
const twentyMinutesFromNow = new Date(now.getTime() + 20 * 60 * 1000);
|
||
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
|
||
|
||
// Find events needing 20-minute reminder
|
||
// Events starting within 20 minutes that haven't had reminder sent
|
||
const eventsFor20MinReminder = await this.prisma.scheduleEvent.findMany({
|
||
where: {
|
||
status: EventStatus.SCHEDULED,
|
||
type: 'TRANSPORT',
|
||
startTime: { lte: twentyMinutesFromNow, gt: now },
|
||
reminder20MinSent: false,
|
||
driverId: { not: null },
|
||
},
|
||
include: {
|
||
driver: true,
|
||
vehicle: true,
|
||
},
|
||
});
|
||
|
||
for (const event of eventsFor20MinReminder) {
|
||
// Only send if actually ~20 min away (between 15-25 min)
|
||
const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000);
|
||
if (minutesUntil <= 25 && minutesUntil >= 15) {
|
||
await this.send20MinReminder(event, minutesUntil);
|
||
}
|
||
}
|
||
|
||
// Find events needing 5-minute reminder
|
||
const eventsFor5MinReminder = await this.prisma.scheduleEvent.findMany({
|
||
where: {
|
||
status: EventStatus.SCHEDULED,
|
||
type: 'TRANSPORT',
|
||
startTime: { lte: fiveMinutesFromNow, gt: now },
|
||
reminder5MinSent: false,
|
||
driverId: { not: null },
|
||
},
|
||
include: {
|
||
driver: true,
|
||
vehicle: true,
|
||
},
|
||
});
|
||
|
||
for (const event of eventsFor5MinReminder) {
|
||
// Only send if actually ~5 min away (between 3-10 min)
|
||
const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000);
|
||
if (minutesUntil <= 10 && minutesUntil >= 3) {
|
||
await this.send5MinReminder(event, minutesUntil);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send 20-minute reminder to driver
|
||
*/
|
||
private async send20MinReminder(event: any, minutesUntil: number) {
|
||
try {
|
||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||
if (!linkedNumber || !event.driver?.phone) return;
|
||
|
||
// Get VIP names
|
||
const vips = await this.prisma.vIP.findMany({
|
||
where: { id: { in: event.vipIds || [] } },
|
||
select: { name: true },
|
||
});
|
||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||
|
||
const message = `📢 UPCOMING TRIP in ~${minutesUntil} minutes
|
||
|
||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||
📍 Dropoff: ${event.dropoffLocation || 'See schedule'}
|
||
👤 VIP: ${vipNames}
|
||
🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'}
|
||
⏰ Start Time: ${new Date(event.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||
|
||
Please head to the pickup location.`;
|
||
|
||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||
|
||
// Mark reminder as sent
|
||
await this.prisma.scheduleEvent.update({
|
||
where: { id: event.id },
|
||
data: { reminder20MinSent: true },
|
||
});
|
||
|
||
this.logger.log(`Sent 20-min reminder to ${event.driver.name} for event ${event.id}`);
|
||
} catch (error) {
|
||
this.logger.error(`Failed to send 20-min reminder for event ${event.id}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send 5-minute reminder to driver (more urgent)
|
||
*/
|
||
private async send5MinReminder(event: any, minutesUntil: number) {
|
||
try {
|
||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||
if (!linkedNumber || !event.driver?.phone) return;
|
||
|
||
// Get VIP names
|
||
const vips = await this.prisma.vIP.findMany({
|
||
where: { id: { in: event.vipIds || [] } },
|
||
select: { name: true },
|
||
});
|
||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||
|
||
const message = `⚠️ TRIP STARTING in ${minutesUntil} MINUTES!
|
||
|
||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||
👤 VIP: ${vipNames}
|
||
🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'}
|
||
|
||
You should be at the pickup location NOW.
|
||
|
||
Reply:
|
||
1️⃣ = Ready and waiting
|
||
2️⃣ = Running late
|
||
3️⃣ = Issue / Need help`;
|
||
|
||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||
|
||
// Mark reminder as sent
|
||
await this.prisma.scheduleEvent.update({
|
||
where: { id: event.id },
|
||
data: { reminder5MinSent: true },
|
||
});
|
||
|
||
this.logger.log(`Sent 5-min reminder to ${event.driver.name} for event ${event.id}`);
|
||
} catch (error) {
|
||
this.logger.error(`Failed to send 5-min reminder for event ${event.id}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Transition SCHEDULED → IN_PROGRESS for events whose startTime has passed
|
||
*/
|
||
private async transitionToInProgress(now: Date) {
|
||
const eventsToStart = await this.prisma.scheduleEvent.findMany({
|
||
where: {
|
||
status: EventStatus.SCHEDULED,
|
||
startTime: { lte: now },
|
||
},
|
||
include: {
|
||
driver: true,
|
||
vehicle: true,
|
||
},
|
||
});
|
||
|
||
for (const event of eventsToStart) {
|
||
try {
|
||
// Update status to IN_PROGRESS
|
||
await this.prisma.scheduleEvent.update({
|
||
where: { id: event.id },
|
||
data: {
|
||
status: EventStatus.IN_PROGRESS,
|
||
actualStartTime: now,
|
||
},
|
||
});
|
||
|
||
this.logger.log(`Event ${event.id} (${event.title}) auto-started`);
|
||
|
||
// Send Signal confirmation request to driver if assigned
|
||
if (event.driver?.phone) {
|
||
await this.sendDriverConfirmationRequest(event);
|
||
}
|
||
} catch (error) {
|
||
this.logger.error(`Failed to transition event ${event.id}:`, error);
|
||
}
|
||
}
|
||
|
||
if (eventsToStart.length > 0) {
|
||
this.logger.log(`Auto-started ${eventsToStart.length} events`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Transition IN_PROGRESS → COMPLETED for events past their endTime + grace period
|
||
* Only auto-complete if no driver confirmation is pending
|
||
*/
|
||
private async transitionToCompleted(now: Date) {
|
||
const gracePeriodAgo = new Date(now.getTime() - this.COMPLETION_GRACE_PERIOD);
|
||
|
||
const eventsToComplete = await this.prisma.scheduleEvent.findMany({
|
||
where: {
|
||
status: EventStatus.IN_PROGRESS,
|
||
endTime: { lte: gracePeriodAgo },
|
||
},
|
||
include: {
|
||
driver: true,
|
||
},
|
||
});
|
||
|
||
for (const event of eventsToComplete) {
|
||
try {
|
||
await this.prisma.scheduleEvent.update({
|
||
where: { id: event.id },
|
||
data: {
|
||
status: EventStatus.COMPLETED,
|
||
actualEndTime: now,
|
||
},
|
||
});
|
||
|
||
this.logger.log(`Event ${event.id} (${event.title}) auto-completed`);
|
||
} catch (error) {
|
||
this.logger.error(`Failed to complete event ${event.id}:`, error);
|
||
}
|
||
}
|
||
|
||
if (eventsToComplete.length > 0) {
|
||
this.logger.log(`Auto-completed ${eventsToComplete.length} events`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send a Signal message to the driver asking for confirmation
|
||
*/
|
||
private async sendDriverConfirmationRequest(event: any) {
|
||
try {
|
||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||
if (!linkedNumber) {
|
||
this.logger.warn('No Signal account linked, skipping driver notification');
|
||
return;
|
||
}
|
||
|
||
// Get VIP names for the message
|
||
const vips = await this.prisma.vIP.findMany({
|
||
where: { id: { in: event.vipIds || [] } },
|
||
select: { name: true },
|
||
});
|
||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||
|
||
const message = `🚗 TRIP STARTED: ${event.title}
|
||
|
||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||
📍 Dropoff: ${event.dropoffLocation || 'See schedule'}
|
||
👤 VIP: ${vipNames}
|
||
🚐 Vehicle: ${event.vehicle?.name || 'Not assigned'}
|
||
|
||
Please confirm status:
|
||
1️⃣ = En route / Confirmed
|
||
2️⃣ = Delayed (explain in next message)
|
||
3️⃣ = Issue / Need help
|
||
|
||
Reply with 1, 2, or 3`;
|
||
|
||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||
|
||
this.logger.log(`Sent confirmation request to driver ${event.driver.name} for event ${event.id}`);
|
||
} catch (error) {
|
||
this.logger.error(`Failed to send Signal confirmation for event ${event.id}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Process a driver's response to a confirmation request
|
||
* Called by the Signal message handler when a driver replies with 1, 2, or 3
|
||
*/
|
||
async processDriverResponse(driverPhone: string, response: string): Promise<string | null> {
|
||
const responseNum = parseInt(response.trim(), 10);
|
||
if (![1, 2, 3].includes(responseNum)) {
|
||
return null; // Not a status response
|
||
}
|
||
|
||
// Find the driver
|
||
const driver = await this.prisma.driver.findFirst({
|
||
where: {
|
||
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
||
},
|
||
});
|
||
|
||
if (!driver) {
|
||
return null;
|
||
}
|
||
|
||
// Find their current IN_PROGRESS event
|
||
const activeEvent = await this.prisma.scheduleEvent.findFirst({
|
||
where: {
|
||
driverId: driver.id,
|
||
status: EventStatus.IN_PROGRESS,
|
||
},
|
||
include: { vehicle: true },
|
||
});
|
||
|
||
if (!activeEvent) {
|
||
return 'No active trip found. Reply ignored.';
|
||
}
|
||
|
||
let replyMessage: string;
|
||
|
||
switch (responseNum) {
|
||
case 1: // Confirmed
|
||
// Event is already IN_PROGRESS, this just confirms it
|
||
await this.prisma.scheduleEvent.update({
|
||
where: { id: activeEvent.id },
|
||
data: {
|
||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver confirmed en route`.trim(),
|
||
},
|
||
});
|
||
replyMessage = `✅ Confirmed! Safe travels. Reply when completed or if you need assistance.`;
|
||
break;
|
||
|
||
case 2: // Delayed
|
||
await this.prisma.scheduleEvent.update({
|
||
where: { id: activeEvent.id },
|
||
data: {
|
||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported DELAY`.trim(),
|
||
},
|
||
});
|
||
replyMessage = `⏰ Delay noted. Please reply with details about the delay. Coordinator has been alerted.`;
|
||
break;
|
||
|
||
case 3: // Issue
|
||
await this.prisma.scheduleEvent.update({
|
||
where: { id: activeEvent.id },
|
||
data: {
|
||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported ISSUE - needs help`.trim(),
|
||
},
|
||
});
|
||
replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the issue in your next message.`;
|
||
break;
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
|
||
// Send the reply
|
||
try {
|
||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||
if (linkedNumber && driver.phone) {
|
||
const formattedPhone = this.signalService.formatPhoneNumber(driver.phone);
|
||
await this.signalService.sendMessage(linkedNumber, formattedPhone, replyMessage);
|
||
}
|
||
} catch (error) {
|
||
this.logger.error('Failed to send reply to driver:', error);
|
||
}
|
||
|
||
return replyMessage;
|
||
}
|
||
}
|