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 { 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; } }