feat: comprehensive update with Signal, Copilot, themes, and PDF features
## Signal Messaging Integration - Added SignalService for sending messages to drivers via Signal - SignalMessage model for tracking message history - Driver chat modal for real-time messaging - Send schedule via Signal (ICS + PDF attachments) ## AI Copilot - Natural language interface for VIP Coordinator - Capabilities: create VIPs, schedule events, assign drivers - Help and guidance for users - Floating copilot button in UI ## Theme System - Dark/light/system theme support - Color scheme selection (blue, green, purple, orange, red) - ThemeContext for global state - AppearanceMenu in header ## PDF Schedule Export - VIPSchedulePDF component for schedule generation - PDF settings (header, footer, branding) - Preview PDF in browser - Settings stored in database ## Database Migrations - add_signal_messages: SignalMessage model - add_pdf_settings: Settings model for PDF config - add_reminder_tracking: lastReminderSent for events - make_driver_phone_optional: phone field nullable ## Event Management - Event status service for automated updates - IN_PROGRESS/COMPLETED status tracking - Reminder tracking for notifications ## UI/UX Improvements - Driver schedule modal - Improved My Schedule page - Better error handling and loading states - Responsive design improvements ## Other Changes - AGENT_TEAM.md documentation - Seed data improvements - Ability factory updates - Driver profile page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
423
backend/src/events/event-status.service.ts
Normal file
423
backend/src/events/event-status.service.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
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 },
|
||||
deletedAt: 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 },
|
||||
deletedAt: 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 },
|
||||
deletedAt: null,
|
||||
},
|
||||
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 },
|
||||
deletedAt: null,
|
||||
},
|
||||
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) },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
deletedAt: null,
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { EventsController } from './events.controller';
|
||||
import { EventsService } from './events.service';
|
||||
import { EventStatusService } from './event-status.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SignalModule } from '../signal/signal.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
forwardRef(() => SignalModule), // forwardRef to avoid circular dependency
|
||||
],
|
||||
controllers: [
|
||||
EventsController,
|
||||
],
|
||||
providers: [
|
||||
EventsService,
|
||||
EventStatusService,
|
||||
],
|
||||
exports: [
|
||||
EventsService,
|
||||
EventStatusService,
|
||||
],
|
||||
})
|
||||
export class EventsModule {}
|
||||
|
||||
@@ -300,10 +300,11 @@ export class EventsService {
|
||||
|
||||
/**
|
||||
* Enrich event with VIP details fetched separately
|
||||
* Returns both `vips` array and `vip` (first VIP) for backwards compatibility
|
||||
*/
|
||||
private async enrichEventWithVips(event: any) {
|
||||
if (!event.vipIds || event.vipIds.length === 0) {
|
||||
return { ...event, vips: [] };
|
||||
return { ...event, vips: [], vip: null };
|
||||
}
|
||||
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
@@ -313,6 +314,7 @@ export class EventsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { ...event, vips };
|
||||
// Return both vips array and vip (first one) for backwards compatibility
|
||||
return { ...event, vips, vip: vips[0] || null };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user