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:
2026-02-01 19:30:41 +01:00
parent 2d842ed294
commit 3b0b1205df
84 changed files with 12330 additions and 2103 deletions

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