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

@@ -11,6 +11,10 @@ import { DriversModule } from './drivers/drivers.module';
import { VehiclesModule } from './vehicles/vehicles.module';
import { EventsModule } from './events/events.module';
import { FlightsModule } from './flights/flights.module';
import { CopilotModule } from './copilot/copilot.module';
import { SignalModule } from './signal/signal.module';
import { SettingsModule } from './settings/settings.module';
import { SeedModule } from './seed/seed.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
@Module({
@@ -32,6 +36,10 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
VehiclesModule,
EventsModule,
FlightsModule,
CopilotModule,
SignalModule,
SettingsModule,
SeedModule,
],
controllers: [AppController],
providers: [

View File

@@ -25,6 +25,7 @@ export type Subjects =
| 'ScheduleEvent'
| 'Flight'
| 'Vehicle'
| 'Settings'
| 'all';
/**

View File

@@ -0,0 +1,59 @@
import {
Controller,
Post,
Body,
UseGuards,
Logger,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CopilotService } from './copilot.service';
interface ChatMessageDto {
role: 'user' | 'assistant';
content: string | any[];
}
interface ChatRequestDto {
messages: ChatMessageDto[];
}
@Controller('copilot')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CopilotController {
private readonly logger = new Logger(CopilotController.name);
constructor(private readonly copilotService: CopilotService) {}
@Post('chat')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
async chat(
@Body() body: ChatRequestDto,
@CurrentUser() user: any,
) {
this.logger.log(`Copilot chat request from user: ${user.email}`);
try {
const result = await this.copilotService.chat(
body.messages,
user.id,
user.role,
);
return {
success: true,
...result,
};
} catch (error) {
this.logger.error('Copilot chat error:', error);
return {
success: false,
response: 'I encountered an error processing your request. Please try again.',
error: error.message,
};
}
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CopilotController } from './copilot.controller';
import { CopilotService } from './copilot.service';
import { PrismaModule } from '../prisma/prisma.module';
import { SignalModule } from '../signal/signal.module';
import { DriversModule } from '../drivers/drivers.module';
@Module({
imports: [PrismaModule, SignalModule, DriversModule],
controllers: [CopilotController],
providers: [CopilotService],
})
export class CopilotModule {}

View File

@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { DriversController } from './drivers.controller';
import { DriversService } from './drivers.service';
import { ScheduleExportService } from './schedule-export.service';
import { SignalModule } from '../signal/signal.module';
@Module({
imports: [SignalModule],
controllers: [DriversController],
providers: [DriversService],
exports: [DriversService],
providers: [DriversService, ScheduleExportService],
exports: [DriversService, ScheduleExportService],
})
export class DriversModule {}

View File

@@ -52,6 +52,20 @@ export class DriversService {
return driver;
}
async findByUserId(userId: string) {
return this.prisma.driver.findFirst({
where: { userId, deletedAt: null },
include: {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' },
},
},
});
}
async update(id: string, updateDriverDto: UpdateDriverDto) {
const driver = await this.findOne(id);

View File

@@ -6,7 +6,8 @@ export class CreateDriverDto {
name: string;
@IsString()
phone: string;
@IsOptional()
phone?: string;
@IsEnum(Department)
@IsOptional()

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
import { AllExceptionsFilter, HttpExceptionFilter } from './common/filters';
@@ -8,6 +9,10 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Increase body size limit for PDF attachments (base64 encoded)
app.use(json({ limit: '5mb' }));
app.use(urlencoded({ extended: true, limit: '5mb' }));
// Global prefix for all routes
// In production (App Platform), the ingress routes /api to this service
// So we only need /v1 prefix here

View File

@@ -0,0 +1,3 @@
export * from './seed.module';
export * from './seed.service';
export * from './seed.controller';

View File

@@ -0,0 +1,36 @@
import { Controller, Post, Delete, UseGuards, Body } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { SeedService } from './seed.service';
@Controller('seed')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR')
export class SeedController {
constructor(private readonly seedService: SeedService) {}
/**
* Generate all test data in a single fast transaction
*/
@Post('generate')
async generateTestData(@Body() options?: { clearFirst?: boolean }) {
return this.seedService.generateAllTestData(options?.clearFirst ?? true);
}
/**
* Clear all test data instantly
*/
@Delete('clear')
async clearAllData() {
return this.seedService.clearAllData();
}
/**
* Generate only events with dynamic times (keeps existing VIPs/drivers/vehicles)
*/
@Post('generate-events')
async generateDynamicEvents() {
return this.seedService.generateDynamicEvents();
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SeedController } from './seed.controller';
import { SeedService } from './seed.service';
import { PrismaModule } from '../prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [SeedController],
providers: [SeedService],
})
export class SeedModule {}

View File

@@ -0,0 +1,626 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Department, ArrivalMode, EventType, EventStatus, VehicleType, VehicleStatus } from '@prisma/client';
@Injectable()
export class SeedService {
private readonly logger = new Logger(SeedService.name);
constructor(private prisma: PrismaService) {}
/**
* Clear all data using fast deleteMany operations
*/
async clearAllData() {
const start = Date.now();
// Delete in order to respect foreign key constraints
const results = await this.prisma.$transaction([
this.prisma.signalMessage.deleteMany(),
this.prisma.scheduleEvent.deleteMany(),
this.prisma.flight.deleteMany(),
this.prisma.vehicle.deleteMany(),
this.prisma.driver.deleteMany(),
this.prisma.vIP.deleteMany(),
]);
const elapsed = Date.now() - start;
this.logger.log(`Cleared all data in ${elapsed}ms`);
return {
success: true,
elapsed: `${elapsed}ms`,
deleted: {
messages: results[0].count,
events: results[1].count,
flights: results[2].count,
vehicles: results[3].count,
drivers: results[4].count,
vips: results[5].count,
},
};
}
/**
* Generate all test data in a single fast transaction
*/
async generateAllTestData(clearFirst: boolean = true) {
const start = Date.now();
if (clearFirst) {
await this.clearAllData();
}
// Create all entities in a transaction
const result = await this.prisma.$transaction(async (tx) => {
// 1. Create VIPs
const vipData = this.getVIPData();
await tx.vIP.createMany({ data: vipData });
const vips = await tx.vIP.findMany({ orderBy: { createdAt: 'asc' } });
this.logger.log(`Created ${vips.length} VIPs`);
// 2. Create Drivers with shifts
const driverData = this.getDriverData();
await tx.driver.createMany({ data: driverData });
const drivers = await tx.driver.findMany({ orderBy: { createdAt: 'asc' } });
this.logger.log(`Created ${drivers.length} drivers`);
// 3. Create Vehicles
const vehicleData = this.getVehicleData();
await tx.vehicle.createMany({ data: vehicleData });
const vehicles = await tx.vehicle.findMany({ orderBy: { createdAt: 'asc' } });
this.logger.log(`Created ${vehicles.length} vehicles`);
// 4. Create Flights for VIPs arriving by flight
const flightVips = vips.filter(v => v.arrivalMode === 'FLIGHT');
const flightData = this.getFlightData(flightVips);
await tx.flight.createMany({ data: flightData });
const flights = await tx.flight.findMany();
this.logger.log(`Created ${flights.length} flights`);
// 5. Create Events with dynamic times relative to NOW
const eventData = this.getEventData(vips, drivers, vehicles);
await tx.scheduleEvent.createMany({ data: eventData });
const events = await tx.scheduleEvent.findMany();
this.logger.log(`Created ${events.length} events`);
return { vips, drivers, vehicles, flights, events };
});
const elapsed = Date.now() - start;
this.logger.log(`Generated all test data in ${elapsed}ms`);
return {
success: true,
elapsed: `${elapsed}ms`,
created: {
vips: result.vips.length,
drivers: result.drivers.length,
vehicles: result.vehicles.length,
flights: result.flights.length,
events: result.events.length,
},
};
}
/**
* Generate only dynamic events (uses existing VIPs/drivers/vehicles)
*/
async generateDynamicEvents() {
const start = Date.now();
// Clear existing events
await this.prisma.scheduleEvent.deleteMany();
// Get existing entities
const [vips, drivers, vehicles] = await Promise.all([
this.prisma.vIP.findMany({ where: { deletedAt: null } }),
this.prisma.driver.findMany({ where: { deletedAt: null } }),
this.prisma.vehicle.findMany({ where: { deletedAt: null } }),
]);
if (vips.length === 0) {
return { success: false, error: 'No VIPs found. Generate full test data first.' };
}
// Create events
const eventData = this.getEventData(vips, drivers, vehicles);
await this.prisma.scheduleEvent.createMany({ data: eventData });
const elapsed = Date.now() - start;
return {
success: true,
elapsed: `${elapsed}ms`,
created: { events: eventData.length },
};
}
// ============================================================
// DATA GENERATORS
// ============================================================
private getVIPData() {
return [
// OFFICE_OF_DEVELOPMENT (10 VIPs) - Corporate sponsors, foundations, major donors
{ name: 'Sarah Chen', organization: 'Microsoft Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Executive VP - prefers quiet vehicles. Allergic to peanuts.' },
{ name: 'Marcus Johnson', organization: 'The Coca-Cola Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing spouse. Needs wheelchair accessible transport.' },
{ name: 'Jennifer Wu', organization: 'JPMorgan Chase Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Major donor - $500K pledge. VIP treatment essential.' },
{ name: 'Roberto Gonzalez', organization: 'AT&T Inc', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'First time visitor. Interested in STEM programs.' },
{ name: 'Priya Sharma', organization: 'Google LLC', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Vegetarian meals required. Interested in technology merit badges.' },
{ name: 'David Okonkwo', organization: 'Bank of America', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Has rental car for airport. Needs venue transport only.' },
{ name: 'Maria Rodriguez', organization: 'Walmart Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(120), notes: 'Driving from nearby hotel. Call when 30 min out.' },
{ name: 'Yuki Tanaka', organization: 'Honda Motor Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Japanese executive - interpreter may be needed.' },
{ name: 'Thomas Anderson', organization: 'Verizon Communications', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Will use personal driver after airport pickup.' },
{ name: 'Isabella Costa', organization: 'Target Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Taking rideshare from airport. Venue transport needed.' },
// ADMIN (10 VIPs) - BSA Leadership and Staff
{ name: 'Roger A. Krone', organization: 'BSA National President', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'HIGHEST PRIORITY VIP. Security detail traveling with him.' },
{ name: 'Emily Richardson', organization: 'BSA Chief Scout Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(60), notes: 'Has assigned BSA vehicle. No transport needed.' },
{ name: 'Dr. Maya Krishnan', organization: 'BSA National Director of Program', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(180), notes: 'Carpooling with regional directors.' },
{ name: "James O'Brien", organization: 'BSA Northeast Regional Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Traveling with 2 staff members.' },
{ name: 'Fatima Al-Rahman', organization: 'BSA Western Region Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Halal meals required. Prayer room access needed.' },
{ name: 'William Zhang', organization: 'BSA Southern Region Council', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing presentation equipment - need vehicle with cargo space.' },
{ name: 'Sophie Laurent', organization: 'BSA National Volunteer Training', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(240), notes: 'Training materials in personal vehicle.' },
{ name: 'Alexander Volkov', organization: 'BSA High Adventure Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Outdoor enthusiast - prefers walking when possible.' },
{ name: 'Dr. Aisha Patel', organization: 'BSA STEM & Innovation Programs', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(90), notes: 'Demo equipment for STEM showcase. Fragile items!' },
{ name: 'Henrik Larsson', organization: 'BSA International Commissioner', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(150), notes: 'Visiting from Sweden. International guest protocols apply.' },
];
}
private getDriverData() {
const now = new Date();
const shiftStart = new Date(now);
shiftStart.setHours(6, 0, 0, 0);
const shiftEnd = new Date(now);
shiftEnd.setHours(22, 0, 0, 0);
const lateShiftStart = new Date(now);
lateShiftStart.setHours(14, 0, 0, 0);
const lateShiftEnd = new Date(now);
lateShiftEnd.setHours(23, 59, 0, 0);
return [
{ name: 'Michael Thompson', phone: '555-0101', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'Lisa Martinez', phone: '555-0102', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'David Kim', phone: '555-0103', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'Amanda Washington', phone: '555-0104', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'Carlos Hernandez', phone: '555-0105', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: false, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd }, // Off duty until 2pm
{ name: 'Jessica Lee', phone: '555-0106', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'Brandon Jackson', phone: '555-0107', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd },
{ name: 'Nicole Brown', phone: '555-0108', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
];
}
private getVehicleData() {
return [
{ name: 'Blue Van', type: VehicleType.VAN, licensePlate: 'VAN-001', seatCapacity: 12, status: VehicleStatus.AVAILABLE, notes: 'Primary transport van with wheelchair accessibility' },
{ name: 'Suburban #1', type: VehicleType.SUV, licensePlate: 'SUV-101', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Leather interior, ideal for VIP comfort' },
{ name: 'Golf Cart Alpha', type: VehicleType.GOLF_CART, licensePlate: 'GC-A', seatCapacity: 6, status: VehicleStatus.AVAILABLE, notes: 'Quick campus transport, good for short distances' },
{ name: 'Red Van', type: VehicleType.VAN, licensePlate: 'VAN-002', seatCapacity: 8, status: VehicleStatus.AVAILABLE, notes: 'Standard transport van' },
{ name: 'Scout Bus', type: VehicleType.BUS, licensePlate: 'BUS-001', seatCapacity: 25, status: VehicleStatus.AVAILABLE, notes: 'Large group transport, AC equipped' },
{ name: 'Suburban #2', type: VehicleType.SUV, licensePlate: 'SUV-102', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Backup VIP transport' },
{ name: 'Golf Cart Bravo', type: VehicleType.GOLF_CART, licensePlate: 'GC-B', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Quick on-site transport' },
{ name: 'Equipment Truck', type: VehicleType.TRUCK, licensePlate: 'TRK-001', seatCapacity: 3, status: VehicleStatus.MAINTENANCE, notes: 'For equipment and supply runs - currently in maintenance' },
{ name: 'Executive Sedan', type: VehicleType.SEDAN, licensePlate: 'SED-001', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Premium sedan for executive VIPs' },
{ name: 'Golf Cart Charlie', type: VehicleType.GOLF_CART, licensePlate: 'GC-C', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Backup golf cart' },
];
}
private getFlightData(vips: any[]) {
const flights: any[] = [];
const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB'];
const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA'];
const destination = 'SLC'; // Assuming Salt Lake City for the Jamboree
vips.forEach((vip, index) => {
const airline = airlines[index % airlines.length];
const flightNum = `${airline}${1000 + index * 123}`;
const origin = origins[index % origins.length];
// Arrival flight - times relative to now
const arrivalOffset = (index % 8) * 30 - 60; // -60 to +150 minutes from now
const scheduledArrival = this.relativeTime(arrivalOffset);
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000); // 3 hours before
// Some flights are delayed, some landed, some on time
let status = 'scheduled';
let actualArrival = null;
if (arrivalOffset < -30) {
status = 'landed';
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
} else if (arrivalOffset < 0) {
status = 'landing';
} else if (index % 5 === 0) {
status = 'delayed';
}
flights.push({
vipId: vip.id,
flightNumber: flightNum,
flightDate: new Date(),
segment: 1,
departureAirport: origin,
arrivalAirport: destination,
scheduledDeparture,
scheduledArrival,
actualArrival,
status,
});
// Some VIPs have connecting flights (segment 2)
if (index % 4 === 0) {
const connectOrigin = origins[(index + 3) % origins.length];
flights.push({
vipId: vip.id,
flightNumber: `${airline}${500 + index}`,
flightDate: new Date(),
segment: 2,
departureAirport: connectOrigin,
arrivalAirport: origin,
scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000),
scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000),
status: 'landed',
});
}
});
return flights;
}
private getEventData(vips: any[], drivers: any[], vehicles: any[]) {
const events: any[] = [];
const now = new Date();
// Track vehicle assignments to avoid conflicts
// Map of vehicleId -> array of { start: Date, end: Date }
const vehicleSchedule: Map<string, Array<{ start: Date; end: Date }>> = new Map();
const driverSchedule: Map<string, Array<{ start: Date; end: Date }>> = new Map();
// Initialize schedules
vehicles.forEach(v => vehicleSchedule.set(v.id, []));
drivers.forEach(d => driverSchedule.set(d.id, []));
// Check if a time slot conflicts with existing assignments
const hasConflict = (schedule: Array<{ start: Date; end: Date }>, start: Date, end: Date): boolean => {
return schedule.some(slot =>
(start < slot.end && end > slot.start) // Overlapping
);
};
// Find an available vehicle for a time slot
const findAvailableVehicle = (start: Date, end: Date, preferredIndex: number): any | null => {
if (vehicles.length === 0) return null;
// Try preferred vehicle first
const preferred = vehicles[preferredIndex % vehicles.length];
const preferredSchedule = vehicleSchedule.get(preferred.id) || [];
if (!hasConflict(preferredSchedule, start, end)) {
preferredSchedule.push({ start, end });
return preferred;
}
// Try other vehicles
for (const vehicle of vehicles) {
const schedule = vehicleSchedule.get(vehicle.id) || [];
if (!hasConflict(schedule, start, end)) {
schedule.push({ start, end });
return vehicle;
}
}
return null; // No available vehicle
};
// Find an available driver for a time slot
const findAvailableDriver = (start: Date, end: Date, preferredIndex: number): any | null => {
if (drivers.length === 0) return null;
// Try preferred driver first
const preferred = drivers[preferredIndex % drivers.length];
const preferredSchedule = driverSchedule.get(preferred.id) || [];
if (!hasConflict(preferredSchedule, start, end)) {
preferredSchedule.push({ start, end });
return preferred;
}
// Try other drivers
for (const driver of drivers) {
const schedule = driverSchedule.get(driver.id) || [];
if (!hasConflict(schedule, start, end)) {
schedule.push({ start, end });
return driver;
}
}
return null; // No available driver
};
vips.forEach((vip, vipIndex) => {
// ============================================================
// CREATE VARIED EVENTS RELATIVE TO NOW
// ============================================================
// Event pattern based on VIP index to create variety:
// - Some VIPs have events IN_PROGRESS
// - Some have events starting VERY soon (5-15 min)
// - Some have events starting soon (30-60 min)
// - Some have just-completed events
// - All have future events throughout the day
const eventPattern = vipIndex % 5;
switch (eventPattern) {
case 0: { // IN_PROGRESS airport pickup
const start = this.relativeTime(-25);
const end = this.relativeTime(15);
const driver = findAvailableDriver(start, end, vipIndex);
const vehicle = findAvailableVehicle(start, end, vipIndex);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `Airport Pickup - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.IN_PROGRESS,
pickupLocation: 'Airport - Terminal B',
dropoffLocation: 'Main Gate Registration',
startTime: start,
endTime: end,
actualStartTime: this.relativeTime(-23),
description: `ACTIVE: Driver en route with ${vip.name} from airport`,
notes: 'VIP collected from arrivals. ETA 15 minutes.',
});
break;
}
case 1: { // COMPLETED event
const start = this.relativeTime(-90);
const end = this.relativeTime(-45);
const driver = findAvailableDriver(start, end, vipIndex);
const vehicle = findAvailableVehicle(start, end, vipIndex);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `Airport Pickup - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.COMPLETED,
pickupLocation: 'Airport - Terminal A',
dropoffLocation: 'VIP Lodge',
startTime: start,
endTime: end,
actualStartTime: this.relativeTime(-88),
actualEndTime: this.relativeTime(-42),
description: `Completed pickup for ${vip.name}`,
});
break;
}
case 2: { // Starting in 5-10 minutes (URGENT)
const start = this.relativeTime(7);
const end = this.relativeTime(22);
const driver = findAvailableDriver(start, end, vipIndex);
const vehicle = findAvailableVehicle(start, end, vipIndex);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `URGENT: Transport to Opening Ceremony - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Main Arena - VIP Entrance',
startTime: start,
endTime: end,
description: `Pick up ${vip.name} for Opening Ceremony - STARTS SOON!`,
notes: 'Driver should be at pickup location NOW',
});
break;
}
case 3: { // Starting in 30-45 min
const start = this.relativeTime(35);
const end = this.relativeTime(50);
const driver = findAvailableDriver(start, end, vipIndex);
const vehicle = findAvailableVehicle(start, end, vipIndex);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `VIP Lodge Transfer - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'Registration Tent',
dropoffLocation: 'VIP Lodge - Building A',
startTime: start,
endTime: end,
description: `Transfer ${vip.name} to VIP accommodation after registration`,
});
break;
}
case 4: // In-progress MEETING (no driver/vehicle needed)
events.push({
vipIds: [vip.id],
title: `Donor Briefing Meeting`,
type: EventType.MEETING,
status: EventStatus.IN_PROGRESS,
location: 'Conference Center - Room 101',
startTime: this.relativeTime(-20),
endTime: this.relativeTime(25),
actualStartTime: this.relativeTime(-18),
description: `${vip.name} in donor briefing with development team`,
});
break;
}
// ============================================================
// ADD STANDARD DAY EVENTS FOR ALL VIPS
// ============================================================
// Upcoming meal (1-2 hours out) - no driver/vehicle needed
events.push({
vipIds: [vip.id],
title: vipIndex % 2 === 0 ? 'VIP Luncheon' : 'VIP Breakfast',
type: EventType.MEAL,
status: EventStatus.SCHEDULED,
location: 'VIP Dining Pavilion',
startTime: this.relativeTime(60 + (vipIndex % 4) * 15),
endTime: this.relativeTime(120 + (vipIndex % 4) * 15),
description: `Catered meal for ${vip.name} with other VIP guests`,
});
// Transport to main event (2-3 hours out)
{
const start = this.relativeTime(150 + vipIndex * 5);
const end = this.relativeTime(165 + vipIndex * 5);
const driver = findAvailableDriver(start, end, vipIndex + 3);
const vehicle = findAvailableVehicle(start, end, vipIndex + 2);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `Transport to Scout Exhibition`,
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Exhibition Grounds',
startTime: start,
endTime: end,
description: `Transport ${vip.name} to Scout Exhibition area`,
});
}
// Main event (3-4 hours out) - no driver/vehicle needed
events.push({
vipIds: [vip.id],
title: 'Scout Skills Exhibition',
type: EventType.EVENT,
status: EventStatus.SCHEDULED,
location: 'Exhibition Grounds - Zone A',
startTime: this.relativeTime(180 + vipIndex * 3),
endTime: this.relativeTime(270 + vipIndex * 3),
description: `${vip.name} tours Scout exhibitions and demonstrations`,
});
// Evening dinner (5-6 hours out) - no driver/vehicle needed
events.push({
vipIds: [vip.id],
title: 'Gala Dinner',
type: EventType.MEAL,
status: EventStatus.SCHEDULED,
location: 'Grand Ballroom',
startTime: this.relativeTime(360),
endTime: this.relativeTime(480),
description: `Black-tie dinner event with ${vip.name} and other distinguished guests`,
});
// Next day departure (tomorrow morning)
if (vip.arrivalMode === 'FLIGHT') {
const start = this.relativeTime(60 * 24 + 120 + vipIndex * 20);
const end = this.relativeTime(60 * 24 + 165 + vipIndex * 20);
const driver = findAvailableDriver(start, end, vipIndex + 1);
const vehicle = findAvailableVehicle(start, end, vipIndex + 1);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `Airport Departure - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Airport - Departures',
startTime: start,
endTime: end,
description: `Transport ${vip.name} to airport for departure flight`,
notes: 'Confirm flight status before pickup',
});
}
});
// ============================================================
// ADD MULTI-VIP GROUP EVENTS
// ============================================================
if (vips.length >= 4) {
// Group transport with multiple VIPs
{
const start = this.relativeTime(45);
const end = this.relativeTime(60);
const driver = findAvailableDriver(start, end, 0);
// Find a large vehicle (bus or van with capacity >= 8)
const largeVehicle = vehicles.find(v =>
v.seatCapacity >= 8 && !hasConflict(vehicleSchedule.get(v.id) || [], start, end)
);
if (largeVehicle) {
vehicleSchedule.get(largeVehicle.id)?.push({ start, end });
}
events.push({
vipIds: [vips[0].id, vips[1].id, vips[2].id],
driverId: driver?.id,
vehicleId: largeVehicle?.id,
title: 'Group Transport - Leadership Briefing',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'VIP Lodge - Main Entrance',
dropoffLocation: 'National HQ Building',
startTime: start,
endTime: end,
description: 'Multi-VIP transport for leadership briefing session',
notes: 'IMPORTANT: Picking up 3 VIPs - use large vehicle',
});
}
// Another group event
if (vips.length >= 5) {
const start = this.relativeTime(90);
const end = this.relativeTime(110);
const driver = findAvailableDriver(start, end, 1);
const vehicle = findAvailableVehicle(start, end, 1);
events.push({
vipIds: [vips[3].id, vips[4].id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: 'Group Transport - Media Tour',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'Media Center',
dropoffLocation: 'Historical Site',
startTime: start,
endTime: end,
description: 'VIP media tour with photo opportunities',
});
}
}
// ============================================================
// ADD SOME CANCELLED EVENTS FOR REALISM
// ============================================================
if (vips.length >= 6) {
events.push({
vipIds: [vips[5].id],
title: 'Private Meeting - CANCELLED',
type: EventType.MEETING,
status: EventStatus.CANCELLED,
location: 'Conference Room B',
startTime: this.relativeTime(200),
endTime: this.relativeTime(260),
description: 'Meeting cancelled due to schedule conflict',
notes: 'VIP requested reschedule for tomorrow',
});
}
return events;
}
// ============================================================
// HELPER METHODS
// ============================================================
/**
* Get a date relative to now
* @param minutesOffset - Minutes from now (negative = past, positive = future)
*/
private relativeTime(minutesOffset: number): Date {
return new Date(Date.now() + minutesOffset * 60 * 1000);
}
}

View File

@@ -0,0 +1,105 @@
import {
IsString,
IsEmail,
IsBoolean,
IsEnum,
IsOptional,
IsHexColor,
MaxLength,
} from 'class-validator';
import { PageSize } from '@prisma/client';
export class UpdatePdfSettingsDto {
// Branding
@IsOptional()
@IsString()
@MaxLength(100)
organizationName?: string;
@IsOptional()
@IsHexColor()
accentColor?: string;
@IsOptional()
@IsString()
@MaxLength(200)
tagline?: string;
// Contact Info
@IsOptional()
@IsEmail()
contactEmail?: string;
@IsOptional()
@IsString()
@MaxLength(50)
contactPhone?: string;
@IsOptional()
@IsString()
@MaxLength(100)
secondaryContactName?: string;
@IsOptional()
@IsString()
@MaxLength(50)
secondaryContactPhone?: string;
@IsOptional()
@IsString()
@MaxLength(100)
contactLabel?: string;
// Document Options
@IsOptional()
@IsBoolean()
showDraftWatermark?: boolean;
@IsOptional()
@IsBoolean()
showConfidentialWatermark?: boolean;
@IsOptional()
@IsBoolean()
showTimestamp?: boolean;
@IsOptional()
@IsBoolean()
showAppUrl?: boolean;
@IsOptional()
@IsEnum(PageSize)
pageSize?: PageSize;
// Content Toggles
@IsOptional()
@IsBoolean()
showFlightInfo?: boolean;
@IsOptional()
@IsBoolean()
showDriverNames?: boolean;
@IsOptional()
@IsBoolean()
showVehicleNames?: boolean;
@IsOptional()
@IsBoolean()
showVipNotes?: boolean;
@IsOptional()
@IsBoolean()
showEventDescriptions?: boolean;
// Custom Text
@IsOptional()
@IsString()
@MaxLength(500)
headerMessage?: string;
@IsOptional()
@IsString()
@MaxLength(500)
footerMessage?: string;
}

View File

@@ -0,0 +1,61 @@
import {
Controller,
Get,
Patch,
Post,
Delete,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { SettingsService } from './settings.service';
import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AbilitiesGuard } from '../auth/guards/abilities.guard';
import { CanUpdate } from '../auth/decorators/check-ability.decorator';
@Controller('settings')
@UseGuards(JwtAuthGuard, AbilitiesGuard)
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get('pdf')
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
getPdfSettings() {
return this.settingsService.getPdfSettings();
}
@Patch('pdf')
@CanUpdate('Settings')
updatePdfSettings(@Body() dto: UpdatePdfSettingsDto) {
return this.settingsService.updatePdfSettings(dto);
}
@Post('pdf/logo')
@CanUpdate('Settings')
@UseInterceptors(FileInterceptor('logo'))
uploadLogo(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB
new FileTypeValidator({ fileType: /(png|jpeg|jpg|svg\+xml)/ }),
],
}),
)
file: Express.Multer.File,
) {
return this.settingsService.uploadLogo(file);
}
@Delete('pdf/logo')
@CanUpdate('Settings')
deleteLogo() {
return this.settingsService.deleteLogo();
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { PrismaModule } from '../prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,139 @@
import {
Injectable,
Logger,
BadRequestException,
InternalServerErrorException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto';
import { PdfSettings } from '@prisma/client';
@Injectable()
export class SettingsService {
private readonly logger = new Logger(SettingsService.name);
private readonly MAX_LOGO_SIZE = 2 * 1024 * 1024; // 2MB in bytes
private readonly ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml'];
constructor(private prisma: PrismaService) {}
/**
* Get PDF settings - creates default if none exist (singleton pattern)
*/
async getPdfSettings(): Promise<PdfSettings> {
this.logger.log('Fetching PDF settings');
let settings = await this.prisma.pdfSettings.findFirst();
if (!settings) {
this.logger.log('No settings found, creating defaults');
settings = await this.prisma.pdfSettings.create({
data: {
organizationName: 'VIP Coordinator',
accentColor: '#2c3e50',
contactEmail: 'contact@example.com',
contactPhone: '555-0100',
contactLabel: 'Questions or Changes?',
pageSize: 'LETTER',
showDraftWatermark: false,
showConfidentialWatermark: false,
showTimestamp: true,
showAppUrl: false,
showFlightInfo: true,
showDriverNames: true,
showVehicleNames: true,
showVipNotes: true,
showEventDescriptions: true,
},
});
this.logger.log(`Created default settings: ${settings.id}`);
}
return settings;
}
/**
* Update PDF settings
*/
async updatePdfSettings(dto: UpdatePdfSettingsDto): Promise<PdfSettings> {
this.logger.log('Updating PDF settings');
// Get existing settings (or create if none exist)
const existing = await this.getPdfSettings();
try {
const updated = await this.prisma.pdfSettings.update({
where: { id: existing.id },
data: dto,
});
this.logger.log(`Settings updated: ${updated.id}`);
return updated;
} catch (error) {
this.logger.error(`Failed to update settings: ${error.message}`);
throw new InternalServerErrorException('Failed to update PDF settings');
}
}
/**
* Upload logo as base64 data URL
*/
async uploadLogo(file: Express.Multer.File): Promise<PdfSettings> {
this.logger.log(`Uploading logo: ${file.originalname} (${file.size} bytes)`);
// Validate file size
if (file.size > this.MAX_LOGO_SIZE) {
throw new BadRequestException(
`Logo file too large. Maximum size is ${this.MAX_LOGO_SIZE / 1024 / 1024}MB`,
);
}
// Validate MIME type
if (!this.ALLOWED_MIME_TYPES.includes(file.mimetype)) {
throw new BadRequestException(
`Invalid file type. Allowed types: PNG, JPG, SVG`,
);
}
// Convert to base64 data URL
const base64 = file.buffer.toString('base64');
const dataUrl = `data:${file.mimetype};base64,${base64}`;
// Get existing settings
const existing = await this.getPdfSettings();
try {
const updated = await this.prisma.pdfSettings.update({
where: { id: existing.id },
data: { logoUrl: dataUrl },
});
this.logger.log(`Logo uploaded: ${file.originalname}`);
return updated;
} catch (error) {
this.logger.error(`Failed to upload logo: ${error.message}`);
throw new InternalServerErrorException('Failed to upload logo');
}
}
/**
* Delete logo
*/
async deleteLogo(): Promise<PdfSettings> {
this.logger.log('Deleting logo');
const existing = await this.getPdfSettings();
try {
const updated = await this.prisma.pdfSettings.update({
where: { id: existing.id },
data: { logoUrl: null },
});
this.logger.log('Logo deleted');
return updated;
} catch (error) {
this.logger.error(`Failed to delete logo: ${error.message}`);
throw new InternalServerErrorException('Failed to delete logo');
}
}
}

View File

@@ -0,0 +1,200 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Logger,
Res,
} from '@nestjs/common';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { MessagesService, SendMessageDto } from './messages.service';
// DTO for incoming Signal webhook
interface SignalWebhookPayload {
envelope: {
source: string;
sourceNumber?: string;
sourceName?: string;
timestamp: number;
dataMessage?: {
timestamp: number;
message: string;
};
};
account: string;
}
@Controller('signal/messages')
export class MessagesController {
private readonly logger = new Logger(MessagesController.name);
constructor(private readonly messagesService: MessagesService) {}
/**
* Get messages for a specific driver
*/
@Get('driver/:driverId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR', 'COORDINATOR')
async getMessagesForDriver(
@Param('driverId') driverId: string,
@Query('limit') limit?: string,
) {
const messages = await this.messagesService.getMessagesForDriver(
driverId,
limit ? parseInt(limit, 10) : 50,
);
// Return in chronological order for display
return messages.reverse();
}
/**
* Send a message to a driver
*/
@Post('send')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR', 'COORDINATOR')
async sendMessage(@Body() dto: SendMessageDto) {
return this.messagesService.sendMessage(dto);
}
/**
* Mark messages as read for a driver
*/
@Post('driver/:driverId/read')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR', 'COORDINATOR')
async markAsRead(@Param('driverId') driverId: string) {
const result = await this.messagesService.markMessagesAsRead(driverId);
return { success: true, count: result.count };
}
/**
* Get unread message counts for all drivers
*/
@Get('unread')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR', 'COORDINATOR')
async getUnreadCounts() {
return this.messagesService.getUnreadCounts();
}
/**
* Get unread count for a specific driver
*/
@Get('driver/:driverId/unread')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR', 'COORDINATOR')
async getUnreadCountForDriver(@Param('driverId') driverId: string) {
const count = await this.messagesService.getUnreadCountForDriver(driverId);
return { driverId, unread: count };
}
/**
* Webhook endpoint for incoming Signal messages
* This is called by signal-cli-rest-api when messages are received
* Public endpoint - no authentication required
*/
@Public()
@Post('webhook')
async handleWebhook(@Body() payload: SignalWebhookPayload) {
this.logger.debug('Received Signal webhook:', JSON.stringify(payload));
try {
const envelope = payload.envelope;
if (!envelope?.dataMessage?.message) {
this.logger.debug('Webhook received but no message content');
return { success: true, message: 'No message content' };
}
const fromNumber = envelope.sourceNumber || envelope.source;
const content = envelope.dataMessage.message;
const timestamp = envelope.dataMessage.timestamp?.toString();
const message = await this.messagesService.processIncomingMessage(
fromNumber,
content,
timestamp,
);
if (message) {
return { success: true, messageId: message.id };
} else {
return { success: true, message: 'Unknown sender' };
}
} catch (error: any) {
this.logger.error('Failed to process webhook:', error.message);
return { success: false, error: error.message };
}
}
/**
* Export all messages as a text file
*/
@Get('export')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR')
async exportMessages(@Res() res: Response) {
const exportData = await this.messagesService.exportAllMessages();
const filename = `signal-chats-${new Date().toISOString().split('T')[0]}.txt`;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(exportData);
}
/**
* Delete all messages
*/
@Delete('all')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR')
async deleteAllMessages() {
const count = await this.messagesService.deleteAllMessages();
return { success: true, deleted: count };
}
/**
* Get message statistics
*/
@Get('stats')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR', 'COORDINATOR')
async getMessageStats() {
return this.messagesService.getMessageStats();
}
/**
* Check which events have driver responses since the event started
* Used to determine if the "awaiting response" glow should show
*/
@Post('check-responses')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMINISTRATOR', 'COORDINATOR')
async checkDriverResponses(
@Body()
body: {
events: Array<{ eventId: string; driverId: string; startTime: string }>;
},
) {
const pairs = body.events.map((e) => ({
eventId: e.eventId,
driverId: e.driverId,
sinceTime: new Date(e.startTime),
}));
const respondedEventIds =
await this.messagesService.checkDriverResponsesSince(pairs);
return { respondedEventIds };
}
}

View File

@@ -0,0 +1,432 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SignalService } from './signal.service';
import { MessageDirection, EventStatus } from '@prisma/client';
export interface SendMessageDto {
driverId: string;
content: string;
}
export interface MessageWithDriver {
id: string;
driverId: string;
direction: MessageDirection;
content: string;
timestamp: Date;
isRead: boolean;
driver: {
id: string;
name: string;
phone: string;
};
}
@Injectable()
export class MessagesService {
private readonly logger = new Logger(MessagesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly signalService: SignalService,
) {}
/**
* Get all messages for a driver
*/
async getMessagesForDriver(driverId: string, limit: number = 50) {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
});
if (!driver) {
throw new NotFoundException(`Driver with ID ${driverId} not found`);
}
return this.prisma.signalMessage.findMany({
where: { driverId },
orderBy: { timestamp: 'desc' },
take: limit,
});
}
/**
* Send a message to a driver
*/
async sendMessage(dto: SendMessageDto) {
const driver = await this.prisma.driver.findFirst({
where: { id: dto.driverId, deletedAt: null },
});
if (!driver) {
throw new NotFoundException(`Driver with ID ${dto.driverId} not found`);
}
// Get the linked Signal number
const fromNumber = await this.signalService.getLinkedNumber();
if (!fromNumber) {
throw new Error('No Signal account linked. Please link an account in Admin Tools.');
}
// Check driver has a phone number
if (!driver.phone) {
throw new Error('Driver does not have a phone number configured.');
}
// Format the driver's phone number
const toNumber = this.signalService.formatPhoneNumber(driver.phone);
// Send via Signal
const result = await this.signalService.sendMessage(fromNumber, toNumber, dto.content);
if (!result.success) {
throw new Error(result.error || 'Failed to send message via Signal');
}
// Store the message in database
const message = await this.prisma.signalMessage.create({
data: {
driverId: dto.driverId,
direction: MessageDirection.OUTBOUND,
content: dto.content,
isRead: true, // Outbound messages are always "read"
signalTimestamp: result.timestamp?.toString(),
},
});
this.logger.log(`Message sent to driver ${driver.name} (${toNumber})`);
return message;
}
/**
* Process incoming message from Signal webhook
*/
async processIncomingMessage(
fromNumber: string,
content: string,
signalTimestamp?: string,
) {
// Normalize phone number for matching
const normalizedPhone = this.normalizePhoneForSearch(fromNumber);
// Find driver by phone number
const driver = await this.prisma.driver.findFirst({
where: {
deletedAt: null,
OR: [
{ phone: fromNumber },
{ phone: normalizedPhone },
{ phone: { contains: normalizedPhone.slice(-10) } }, // Last 10 digits
],
},
});
if (!driver) {
this.logger.warn(`Received message from unknown number: ${fromNumber}`);
return null;
}
// Check for duplicate message
if (signalTimestamp) {
const existing = await this.prisma.signalMessage.findFirst({
where: {
driverId: driver.id,
signalTimestamp,
},
});
if (existing) {
this.logger.debug(`Duplicate message ignored: ${signalTimestamp}`);
return existing;
}
}
// Store the message
const message = await this.prisma.signalMessage.create({
data: {
driverId: driver.id,
direction: MessageDirection.INBOUND,
content,
isRead: false,
signalTimestamp,
},
});
this.logger.log(`Incoming message from driver ${driver.name}: ${content.substring(0, 50)}...`);
// Check if this is a status response (1, 2, or 3)
const trimmedContent = content.trim();
if (['1', '2', '3'].includes(trimmedContent)) {
await this.processDriverStatusResponse(driver, parseInt(trimmedContent, 10));
}
return message;
}
/**
* Process a driver's status response (1=Confirmed, 2=Delayed, 3=Issue)
*/
private async processDriverStatusResponse(driver: any, response: number) {
// Find the driver's 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) {
// No active event, send a clarification
await this.sendAutoReply(driver, 'No active trip found for your response. If you need assistance, please send a message to the coordinator.');
return;
}
const now = new Date();
let replyMessage: string;
let noteText: string;
switch (response) {
case 1: // Confirmed
noteText = `[${now.toLocaleTimeString()}] ✅ Driver confirmed en route`;
replyMessage = `✅ Confirmed! Safe travels with your VIP. Reply when completed or if you need assistance.`;
break;
case 2: // Delayed
noteText = `[${now.toLocaleTimeString()}] ⏰ Driver reported DELAY - awaiting details`;
replyMessage = `⏰ Delay noted. Please reply with the reason for the delay. The coordinator has been alerted.`;
break;
case 3: // Issue
noteText = `[${now.toLocaleTimeString()}] 🚨 Driver reported ISSUE - needs help`;
replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the problem in your next message.`;
break;
default:
return;
}
// Update the event with the driver's response
await this.prisma.scheduleEvent.update({
where: { id: activeEvent.id },
data: {
notes: activeEvent.notes
? `${activeEvent.notes}\n${noteText}`
: noteText,
},
});
this.logger.log(`Driver ${driver.name} responded with ${response} for event ${activeEvent.id}`);
// Send auto-reply
await this.sendAutoReply(driver, replyMessage);
}
/**
* Send an automated reply to a driver
*/
private async sendAutoReply(driver: any, message: string) {
try {
const fromNumber = await this.signalService.getLinkedNumber();
if (!fromNumber) {
this.logger.warn('No Signal account linked, cannot send auto-reply');
return;
}
const toNumber = this.signalService.formatPhoneNumber(driver.phone);
await this.signalService.sendMessage(fromNumber, toNumber, message);
// Store the outbound message
await this.prisma.signalMessage.create({
data: {
driverId: driver.id,
direction: MessageDirection.OUTBOUND,
content: message,
isRead: true,
},
});
this.logger.log(`Auto-reply sent to driver ${driver.name}`);
} catch (error) {
this.logger.error(`Failed to send auto-reply to driver ${driver.name}:`, error);
}
}
/**
* Mark messages as read for a driver
*/
async markMessagesAsRead(driverId: string) {
return this.prisma.signalMessage.updateMany({
where: {
driverId,
direction: MessageDirection.INBOUND,
isRead: false,
},
data: { isRead: true },
});
}
/**
* Get unread message count per driver
*/
async getUnreadCounts() {
const result = await this.prisma.signalMessage.groupBy({
by: ['driverId'],
where: {
direction: MessageDirection.INBOUND,
isRead: false,
},
_count: true,
});
return result.reduce((acc, item) => {
acc[item.driverId] = item._count;
return acc;
}, {} as Record<string, number>);
}
/**
* Get unread count for a specific driver
*/
async getUnreadCountForDriver(driverId: string) {
return this.prisma.signalMessage.count({
where: {
driverId,
direction: MessageDirection.INBOUND,
isRead: false,
},
});
}
/**
* Normalize phone number for database searching
*/
private normalizePhoneForSearch(phone: string): string {
return phone.replace(/\D/g, '');
}
/**
* Export all messages as formatted text
*/
async exportAllMessages(): Promise<string> {
const messages = await this.prisma.signalMessage.findMany({
include: {
driver: {
select: { id: true, name: true, phone: true },
},
},
orderBy: [
{ driverId: 'asc' },
{ timestamp: 'asc' },
],
});
if (messages.length === 0) {
return 'No messages to export.';
}
// Group messages by driver
const byDriver: Record<string, typeof messages> = {};
for (const msg of messages) {
const driverId = msg.driverId;
if (!byDriver[driverId]) {
byDriver[driverId] = [];
}
byDriver[driverId].push(msg);
}
// Format output
const lines: string[] = [];
lines.push('='.repeat(60));
lines.push('SIGNAL CHAT EXPORT');
lines.push(`Exported: ${new Date().toISOString()}`);
lines.push(`Total Messages: ${messages.length}`);
lines.push('='.repeat(60));
lines.push('');
for (const [driverId, driverMessages] of Object.entries(byDriver)) {
const driver = driverMessages[0]?.driver;
lines.push('-'.repeat(60));
lines.push(`DRIVER: ${driver?.name || 'Unknown'}`);
lines.push(`Phone: ${driver?.phone || 'N/A'}`);
lines.push(`Messages: ${driverMessages.length}`);
lines.push('-'.repeat(60));
for (const msg of driverMessages) {
const direction = msg.direction === 'INBOUND' ? '← IN ' : '→ OUT';
const time = new Date(msg.timestamp).toLocaleString();
lines.push(`[${time}] ${direction}: ${msg.content}`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* Delete all messages
*/
async deleteAllMessages(): Promise<number> {
const result = await this.prisma.signalMessage.deleteMany({});
this.logger.log(`Deleted ${result.count} messages`);
return result.count;
}
/**
* Check which driver-event pairs have driver responses since the event started
* @param pairs Array of {driverId, eventId, sinceTime}
* @returns Set of eventIds where the driver has responded since sinceTime
*/
async checkDriverResponsesSince(
pairs: Array<{ driverId: string; eventId: string; sinceTime: Date }>,
): Promise<string[]> {
const respondedEventIds: string[] = [];
for (const pair of pairs) {
const hasResponse = await this.prisma.signalMessage.findFirst({
where: {
driverId: pair.driverId,
direction: MessageDirection.INBOUND,
timestamp: { gte: pair.sinceTime },
},
});
if (hasResponse) {
respondedEventIds.push(pair.eventId);
}
}
return respondedEventIds;
}
/**
* Get message statistics
*/
async getMessageStats() {
const [total, inbound, outbound, unread] = await Promise.all([
this.prisma.signalMessage.count(),
this.prisma.signalMessage.count({
where: { direction: MessageDirection.INBOUND },
}),
this.prisma.signalMessage.count({
where: { direction: MessageDirection.OUTBOUND },
}),
this.prisma.signalMessage.count({
where: { direction: MessageDirection.INBOUND, isRead: false },
}),
]);
const driversWithMessages = await this.prisma.signalMessage.groupBy({
by: ['driverId'],
});
return {
total,
inbound,
outbound,
unread,
driversWithMessages: driversWithMessages.length,
};
}
}

View File

@@ -0,0 +1,115 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { SignalService } from './signal.service';
import { MessagesService } from './messages.service';
@Injectable()
export class SignalPollingService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SignalPollingService.name);
private pollingInterval: NodeJS.Timeout | null = null;
private isPolling = false;
// Poll every 5 seconds
private readonly POLL_INTERVAL_MS = 5000;
constructor(
private readonly signalService: SignalService,
private readonly messagesService: MessagesService,
) {}
onModuleInit() {
this.startPolling();
}
onModuleDestroy() {
this.stopPolling();
}
private startPolling() {
this.logger.log('Starting Signal message polling...');
this.pollingInterval = setInterval(() => this.pollMessages(), this.POLL_INTERVAL_MS);
// Also poll immediately on startup
this.pollMessages();
}
private stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
this.logger.log('Stopped Signal message polling');
}
}
private async pollMessages() {
// Prevent concurrent polling
if (this.isPolling) {
return;
}
this.isPolling = true;
try {
const linkedNumber = await this.signalService.getLinkedNumber();
if (!linkedNumber) {
// No account linked, skip polling
return;
}
const messages = await this.signalService.receiveMessages(linkedNumber);
if (messages && messages.length > 0) {
this.logger.log(`Received ${messages.length} message(s) from Signal`);
for (const msg of messages) {
await this.processMessage(msg);
}
}
} catch (error: any) {
// Only log errors that aren't connection issues (Signal CLI might not be ready)
if (!error.message?.includes('ECONNREFUSED')) {
this.logger.error(`Error polling messages: ${error.message}`);
}
} finally {
this.isPolling = false;
}
}
private async processMessage(msg: any) {
try {
// Signal CLI returns messages in various formats
// We're looking for envelope.dataMessage.message
const envelope = msg.envelope;
if (!envelope) {
return;
}
// Get the sender's phone number
const fromNumber = envelope.sourceNumber || envelope.source;
// Check for data message (regular text message)
const dataMessage = envelope.dataMessage;
if (dataMessage?.message) {
const content = dataMessage.message;
const timestamp = dataMessage.timestamp?.toString();
this.logger.debug(`Processing message from ${fromNumber}: ${content.substring(0, 50)}...`);
await this.messagesService.processIncomingMessage(
fromNumber,
content,
timestamp,
);
}
// Also handle sync messages (messages sent from other linked devices)
const syncMessage = envelope.syncMessage;
if (syncMessage?.sentMessage?.message) {
// This is a message we sent from another device, we can ignore it
// or store it if needed
this.logger.debug('Received sync message (sent from another device)');
}
} catch (error: any) {
this.logger.error(`Error processing message: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,150 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { SignalService, SignalStatus } from './signal.service';
@Controller('signal')
@UseGuards(JwtAuthGuard, RolesGuard)
export class SignalController {
constructor(private readonly signalService: SignalService) {}
/**
* Get Signal connection status
*/
@Get('status')
@Roles('ADMINISTRATOR')
async getStatus(): Promise<SignalStatus> {
return this.signalService.getStatus();
}
/**
* Get QR code for linking device
*/
@Get('qrcode')
@Roles('ADMINISTRATOR')
async getQRCode() {
const result = await this.signalService.getQRCodeLink();
if (!result) {
return {
success: false,
message: 'Device already linked. Unlink first to re-link.',
};
}
return {
success: true,
qrcode: result.qrcode,
};
}
/**
* Register a new phone number
*/
@Post('register')
@Roles('ADMINISTRATOR')
async registerNumber(@Body() body: { phoneNumber: string; captcha?: string }) {
return this.signalService.registerNumber(body.phoneNumber, body.captcha);
}
/**
* Verify phone number with code
*/
@Post('verify')
@Roles('ADMINISTRATOR')
async verifyNumber(@Body() body: { phoneNumber: string; code: string }) {
return this.signalService.verifyNumber(body.phoneNumber, body.code);
}
/**
* Unlink the current account
*/
@Delete('unlink/:phoneNumber')
@Roles('ADMINISTRATOR')
async unlinkAccount(@Param('phoneNumber') phoneNumber: string) {
return this.signalService.unlinkAccount(phoneNumber);
}
/**
* Send a test message
*/
@Post('send')
@Roles('ADMINISTRATOR')
async sendMessage(@Body() body: { to: string; message: string }) {
const fromNumber = await this.signalService.getLinkedNumber();
if (!fromNumber) {
return {
success: false,
error: 'No Signal account linked. Please link an account first.',
};
}
const formattedTo = this.signalService.formatPhoneNumber(body.to);
return this.signalService.sendMessage(fromNumber, formattedTo, body.message);
}
/**
* Send message to multiple recipients
*/
@Post('send-bulk')
@Roles('ADMINISTRATOR', 'COORDINATOR')
async sendBulkMessage(@Body() body: { recipients: string[]; message: string }) {
const fromNumber = await this.signalService.getLinkedNumber();
if (!fromNumber) {
return {
success: false,
error: 'No Signal account linked. Please link an account first.',
};
}
const formattedRecipients = body.recipients.map((r) =>
this.signalService.formatPhoneNumber(r),
);
return this.signalService.sendBulkMessage(
fromNumber,
formattedRecipients,
body.message,
);
}
/**
* Send a PDF or file attachment via Signal
*/
@Post('send-attachment')
@Roles('ADMINISTRATOR', 'COORDINATOR')
async sendAttachment(
@Body()
body: {
to: string;
message?: string;
attachment: string; // Base64 encoded file
filename: string;
mimeType?: string;
},
) {
const fromNumber = await this.signalService.getLinkedNumber();
if (!fromNumber) {
return {
success: false,
error: 'No Signal account linked. Please link an account first.',
};
}
const formattedTo = this.signalService.formatPhoneNumber(body.to);
return this.signalService.sendMessageWithAttachment(
fromNumber,
formattedTo,
body.message || '',
body.attachment,
body.filename,
body.mimeType || 'application/pdf',
);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { SignalService } from './signal.service';
import { SignalController } from './signal.controller';
import { MessagesService } from './messages.service';
import { MessagesController } from './messages.controller';
import { SignalPollingService } from './signal-polling.service';
@Module({
imports: [PrismaModule],
controllers: [SignalController, MessagesController],
providers: [SignalService, MessagesService, SignalPollingService],
exports: [SignalService, MessagesService],
})
export class SignalModule {}

View File

@@ -0,0 +1,327 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
interface SignalAccount {
number: string;
uuid: string;
username?: string;
}
export interface SignalStatus {
isConnected: boolean;
isLinked: boolean;
phoneNumber: string | null;
error?: string;
}
export interface QRCodeResponse {
qrcode: string;
expiresAt?: number;
}
@Injectable()
export class SignalService {
private readonly logger = new Logger(SignalService.name);
private readonly client: AxiosInstance;
private readonly baseUrl: string;
constructor() {
this.baseUrl = process.env.SIGNAL_API_URL || 'http://localhost:8080';
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
});
}
/**
* Check if Signal API is available and get connection status
*/
async getStatus(): Promise<SignalStatus> {
try {
// Check if API is reachable
const response = await this.client.get('/v1/about');
// Try to get registered accounts
// API returns array of phone number strings: ["+1234567890"]
const accountsResponse = await this.client.get('/v1/accounts');
const accounts: string[] = accountsResponse.data;
if (accounts.length > 0) {
return {
isConnected: true,
isLinked: true,
phoneNumber: accounts[0],
};
}
return {
isConnected: true,
isLinked: false,
phoneNumber: null,
};
} catch (error: any) {
this.logger.error('Failed to connect to Signal API:', error.message);
return {
isConnected: false,
isLinked: false,
phoneNumber: null,
error: error.code === 'ECONNREFUSED'
? 'Signal API container is not running'
: error.message,
};
}
}
/**
* Get QR code for linking a new device
*/
async getQRCodeLink(deviceName: string = 'VIP Coordinator'): Promise<QRCodeResponse | null> {
try {
// First check if already linked
const status = await this.getStatus();
if (status.isLinked) {
this.logger.warn('Device already linked to Signal');
return null;
}
// Request QR code for device linking - returns raw PNG image
const response = await this.client.get('/v1/qrcodelink', {
params: { device_name: deviceName },
timeout: 60000, // QR generation can take a moment
responseType: 'arraybuffer', // Get raw binary data
});
// Convert to base64
const base64 = Buffer.from(response.data, 'binary').toString('base64');
return {
qrcode: base64,
};
} catch (error: any) {
this.logger.error('Failed to get QR code:', error.message);
throw error;
}
}
/**
* Register a new phone number (requires verification)
*/
async registerNumber(phoneNumber: string, captcha?: string): Promise<{ success: boolean; message: string }> {
try {
const response = await this.client.post(`/v1/register/${phoneNumber}`, {
captcha,
use_voice: false,
});
return {
success: true,
message: 'Verification code sent. Check your phone.',
};
} catch (error: any) {
this.logger.error('Failed to register number:', error.message);
return {
success: false,
message: error.response?.data?.error || error.message,
};
}
}
/**
* Verify a phone number with the code received
*/
async verifyNumber(phoneNumber: string, verificationCode: string): Promise<{ success: boolean; message: string }> {
try {
const response = await this.client.post(`/v1/register/${phoneNumber}/verify/${verificationCode}`);
return {
success: true,
message: 'Phone number verified and linked successfully!',
};
} catch (error: any) {
this.logger.error('Failed to verify number:', error.message);
return {
success: false,
message: error.response?.data?.error || error.message,
};
}
}
/**
* Unlink/unregister the current account
*/
async unlinkAccount(phoneNumber: string): Promise<{ success: boolean; message: string }> {
try {
await this.client.delete(`/v1/accounts/${phoneNumber}`);
return {
success: true,
message: 'Account unlinked successfully',
};
} catch (error: any) {
this.logger.error('Failed to unlink account:', error.message);
return {
success: false,
message: error.response?.data?.error || error.message,
};
}
}
/**
* Send a message to a recipient
*/
async sendMessage(
fromNumber: string,
toNumber: string,
message: string,
): Promise<{ success: boolean; timestamp?: number; error?: string }> {
try {
const response = await this.client.post(`/v2/send`, {
number: fromNumber,
recipients: [toNumber],
message,
});
this.logger.log(`Message sent to ${toNumber}`);
return {
success: true,
timestamp: response.data.timestamp,
};
} catch (error: any) {
this.logger.error(`Failed to send message to ${toNumber}:`, error.message);
return {
success: false,
error: error.response?.data?.error || error.message,
};
}
}
/**
* Send a message to multiple recipients
*/
async sendBulkMessage(
fromNumber: string,
toNumbers: string[],
message: string,
): Promise<{ success: boolean; sent: number; failed: number; errors: string[] }> {
const results = {
success: true,
sent: 0,
failed: 0,
errors: [] as string[],
};
for (const toNumber of toNumbers) {
const result = await this.sendMessage(fromNumber, toNumber, message);
if (result.success) {
results.sent++;
} else {
results.failed++;
results.errors.push(`${toNumber}: ${result.error}`);
}
}
results.success = results.failed === 0;
return results;
}
/**
* Get the linked phone number (if any)
*/
async getLinkedNumber(): Promise<string | null> {
try {
const response = await this.client.get('/v1/accounts');
// API returns array of phone number strings directly: ["+1234567890"]
const accounts: string[] = response.data;
if (accounts.length > 0) {
return accounts[0];
}
return null;
} catch (error) {
return null;
}
}
/**
* Format phone number for Signal (must include country code)
*/
formatPhoneNumber(phone: string): string {
// Remove all non-digit characters
let cleaned = phone.replace(/\D/g, '');
// Add US country code if not present
if (cleaned.length === 10) {
cleaned = '1' + cleaned;
}
// Add + prefix
if (!cleaned.startsWith('+')) {
cleaned = '+' + cleaned;
}
return cleaned;
}
/**
* Receive pending messages for the account
* This fetches and removes messages from Signal's queue
*/
async receiveMessages(phoneNumber: string): Promise<any[]> {
try {
const response = await this.client.get(`/v1/receive/${phoneNumber}`, {
timeout: 10000,
});
// Response is an array of message envelopes
return response.data || [];
} catch (error: any) {
// Don't log timeout errors or empty responses as errors
if (error.code === 'ECONNABORTED' || error.response?.status === 204) {
return [];
}
throw error;
}
}
/**
* Send a message with a file attachment (PDF, image, etc.)
* @param fromNumber - The sender's phone number
* @param toNumber - The recipient's phone number
* @param message - Optional text message to accompany the attachment
* @param attachment - Base64 encoded file data
* @param filename - Name for the file
* @param mimeType - MIME type of the file (e.g., 'application/pdf')
*/
async sendMessageWithAttachment(
fromNumber: string,
toNumber: string,
message: string,
attachment: string,
filename: string,
mimeType: string = 'application/pdf',
): Promise<{ success: boolean; timestamp?: number; error?: string }> {
try {
// Format: data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>
const base64Attachment = `data:${mimeType};filename=${filename};base64,${attachment}`;
const response = await this.client.post(`/v2/send`, {
number: fromNumber,
recipients: [toNumber],
message: message || '',
base64_attachments: [base64Attachment],
});
this.logger.log(`Message with attachment sent to ${toNumber}: ${filename}`);
return {
success: true,
timestamp: response.data.timestamp,
};
} catch (error: any) {
this.logger.error(`Failed to send attachment to ${toNumber}:`, error.message);
return {
success: false,
error: error.response?.data?.error || error.message,
};
}
}
}