Files
vip-coordinator/backend/src/events/event-status.service.ts
kyle 3bc9cd0bca refactor: complete code efficiency pass (Issues #10, #14, #16)
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>
2026-02-08 16:34:18 +01:00

418 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}