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:
@@ -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: [
|
||||
|
||||
@@ -25,6 +25,7 @@ export type Subjects =
|
||||
| 'ScheduleEvent'
|
||||
| 'Flight'
|
||||
| 'Vehicle'
|
||||
| 'Settings'
|
||||
| 'all';
|
||||
|
||||
/**
|
||||
|
||||
59
backend/src/copilot/copilot.controller.ts
Normal file
59
backend/src/copilot/copilot.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
13
backend/src/copilot/copilot.module.ts
Normal file
13
backend/src/copilot/copilot.module.ts
Normal 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 {}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ export class CreateDriverDto {
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
phone: string;
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsEnum(Department)
|
||||
@IsOptional()
|
||||
|
||||
423
backend/src/events/event-status.service.ts
Normal file
423
backend/src/events/event-status.service.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SignalService } from '../signal/signal.service';
|
||||
import { EventStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Automatic event status management service
|
||||
* - Transitions SCHEDULED → IN_PROGRESS when startTime arrives
|
||||
* - Sends Signal confirmation requests to drivers
|
||||
* - Handles driver responses (1=Confirmed, 2=Delayed, 3=Issue)
|
||||
* - Transitions IN_PROGRESS → COMPLETED when endTime passes (with grace period)
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EventStatusService.name);
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private readonly CHECK_INTERVAL = 60 * 1000; // Check every minute
|
||||
private readonly COMPLETION_GRACE_PERIOD = 15 * 60 * 1000; // 15 min after endTime before auto-complete
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private signalService: SignalService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log('Starting event status monitoring...');
|
||||
this.startMonitoring();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.stopMonitoring();
|
||||
}
|
||||
|
||||
private startMonitoring() {
|
||||
// Run immediately on start
|
||||
this.checkAndUpdateStatuses();
|
||||
|
||||
// Then run every minute
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkAndUpdateStatuses();
|
||||
}, this.CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
private stopMonitoring() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
this.logger.log('Stopped event status monitoring');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main check loop - finds events that need status updates
|
||||
*/
|
||||
private async checkAndUpdateStatuses() {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// 1. Send reminders for upcoming events (20 min and 5 min before)
|
||||
await this.sendUpcomingReminders(now);
|
||||
|
||||
// 2. Find SCHEDULED events that should now be IN_PROGRESS
|
||||
await this.transitionToInProgress(now);
|
||||
|
||||
// 3. Find IN_PROGRESS events that are past their end time (with grace period)
|
||||
await this.transitionToCompleted(now);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Error checking event statuses:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 20-minute and 5-minute reminders to drivers
|
||||
*/
|
||||
private async sendUpcomingReminders(now: Date) {
|
||||
const twentyMinutesFromNow = new Date(now.getTime() + 20 * 60 * 1000);
|
||||
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
|
||||
|
||||
// Find events needing 20-minute reminder
|
||||
// Events starting within 20 minutes that haven't had reminder sent
|
||||
const eventsFor20MinReminder = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
status: EventStatus.SCHEDULED,
|
||||
type: 'TRANSPORT',
|
||||
startTime: { lte: twentyMinutesFromNow, gt: now },
|
||||
reminder20MinSent: false,
|
||||
driverId: { not: null },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const event of eventsFor20MinReminder) {
|
||||
// Only send if actually ~20 min away (between 15-25 min)
|
||||
const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000);
|
||||
if (minutesUntil <= 25 && minutesUntil >= 15) {
|
||||
await this.send20MinReminder(event, minutesUntil);
|
||||
}
|
||||
}
|
||||
|
||||
// Find events needing 5-minute reminder
|
||||
const eventsFor5MinReminder = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
status: EventStatus.SCHEDULED,
|
||||
type: 'TRANSPORT',
|
||||
startTime: { lte: fiveMinutesFromNow, gt: now },
|
||||
reminder5MinSent: false,
|
||||
driverId: { not: null },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const event of eventsFor5MinReminder) {
|
||||
// Only send if actually ~5 min away (between 3-10 min)
|
||||
const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000);
|
||||
if (minutesUntil <= 10 && minutesUntil >= 3) {
|
||||
await this.send5MinReminder(event, minutesUntil);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 20-minute reminder to driver
|
||||
*/
|
||||
private async send20MinReminder(event: any, minutesUntil: number) {
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (!linkedNumber || !event.driver?.phone) return;
|
||||
|
||||
// Get VIP names
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds || [] } },
|
||||
select: { name: true },
|
||||
});
|
||||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||
|
||||
const message = `📢 UPCOMING TRIP in ~${minutesUntil} minutes
|
||||
|
||||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||
📍 Dropoff: ${event.dropoffLocation || 'See schedule'}
|
||||
👤 VIP: ${vipNames}
|
||||
🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'}
|
||||
⏰ Start Time: ${new Date(event.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
|
||||
Please head to the pickup location.`;
|
||||
|
||||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||
|
||||
// Mark reminder as sent
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { reminder20MinSent: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Sent 20-min reminder to ${event.driver.name} for event ${event.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send 20-min reminder for event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 5-minute reminder to driver (more urgent)
|
||||
*/
|
||||
private async send5MinReminder(event: any, minutesUntil: number) {
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (!linkedNumber || !event.driver?.phone) return;
|
||||
|
||||
// Get VIP names
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds || [] } },
|
||||
select: { name: true },
|
||||
});
|
||||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||
|
||||
const message = `⚠️ TRIP STARTING in ${minutesUntil} MINUTES!
|
||||
|
||||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||
👤 VIP: ${vipNames}
|
||||
🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'}
|
||||
|
||||
You should be at the pickup location NOW.
|
||||
|
||||
Reply:
|
||||
1️⃣ = Ready and waiting
|
||||
2️⃣ = Running late
|
||||
3️⃣ = Issue / Need help`;
|
||||
|
||||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||
|
||||
// Mark reminder as sent
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { reminder5MinSent: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Sent 5-min reminder to ${event.driver.name} for event ${event.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send 5-min reminder for event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition SCHEDULED → IN_PROGRESS for events whose startTime has passed
|
||||
*/
|
||||
private async transitionToInProgress(now: Date) {
|
||||
const eventsToStart = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
status: EventStatus.SCHEDULED,
|
||||
startTime: { lte: now },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const event of eventsToStart) {
|
||||
try {
|
||||
// Update status to IN_PROGRESS
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
actualStartTime: now,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Event ${event.id} (${event.title}) auto-started`);
|
||||
|
||||
// Send Signal confirmation request to driver if assigned
|
||||
if (event.driver?.phone) {
|
||||
await this.sendDriverConfirmationRequest(event);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transition event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventsToStart.length > 0) {
|
||||
this.logger.log(`Auto-started ${eventsToStart.length} events`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition IN_PROGRESS → COMPLETED for events past their endTime + grace period
|
||||
* Only auto-complete if no driver confirmation is pending
|
||||
*/
|
||||
private async transitionToCompleted(now: Date) {
|
||||
const gracePeriodAgo = new Date(now.getTime() - this.COMPLETION_GRACE_PERIOD);
|
||||
|
||||
const eventsToComplete = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
endTime: { lte: gracePeriodAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const event of eventsToComplete) {
|
||||
try {
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
status: EventStatus.COMPLETED,
|
||||
actualEndTime: now,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Event ${event.id} (${event.title}) auto-completed`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to complete event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventsToComplete.length > 0) {
|
||||
this.logger.log(`Auto-completed ${eventsToComplete.length} events`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Signal message to the driver asking for confirmation
|
||||
*/
|
||||
private async sendDriverConfirmationRequest(event: any) {
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (!linkedNumber) {
|
||||
this.logger.warn('No Signal account linked, skipping driver notification');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get VIP names for the message
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds || [] } },
|
||||
select: { name: true },
|
||||
});
|
||||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||
|
||||
const message = `🚗 TRIP STARTED: ${event.title}
|
||||
|
||||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||
📍 Dropoff: ${event.dropoffLocation || 'See schedule'}
|
||||
👤 VIP: ${vipNames}
|
||||
🚐 Vehicle: ${event.vehicle?.name || 'Not assigned'}
|
||||
|
||||
Please confirm status:
|
||||
1️⃣ = En route / Confirmed
|
||||
2️⃣ = Delayed (explain in next message)
|
||||
3️⃣ = Issue / Need help
|
||||
|
||||
Reply with 1, 2, or 3`;
|
||||
|
||||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||
|
||||
this.logger.log(`Sent confirmation request to driver ${event.driver.name} for event ${event.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send Signal confirmation for event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a driver's response to a confirmation request
|
||||
* Called by the Signal message handler when a driver replies with 1, 2, or 3
|
||||
*/
|
||||
async processDriverResponse(driverPhone: string, response: string): Promise<string | null> {
|
||||
const responseNum = parseInt(response.trim(), 10);
|
||||
if (![1, 2, 3].includes(responseNum)) {
|
||||
return null; // Not a status response
|
||||
}
|
||||
|
||||
// Find the driver
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: {
|
||||
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find their current IN_PROGRESS event
|
||||
const activeEvent = await this.prisma.scheduleEvent.findFirst({
|
||||
where: {
|
||||
driverId: driver.id,
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { vehicle: true },
|
||||
});
|
||||
|
||||
if (!activeEvent) {
|
||||
return 'No active trip found. Reply ignored.';
|
||||
}
|
||||
|
||||
let replyMessage: string;
|
||||
|
||||
switch (responseNum) {
|
||||
case 1: // Confirmed
|
||||
// Event is already IN_PROGRESS, this just confirms it
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: activeEvent.id },
|
||||
data: {
|
||||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver confirmed en route`.trim(),
|
||||
},
|
||||
});
|
||||
replyMessage = `✅ Confirmed! Safe travels. Reply when completed or if you need assistance.`;
|
||||
break;
|
||||
|
||||
case 2: // Delayed
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: activeEvent.id },
|
||||
data: {
|
||||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported DELAY`.trim(),
|
||||
},
|
||||
});
|
||||
replyMessage = `⏰ Delay noted. Please reply with details about the delay. Coordinator has been alerted.`;
|
||||
break;
|
||||
|
||||
case 3: // Issue
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: activeEvent.id },
|
||||
data: {
|
||||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported ISSUE - needs help`.trim(),
|
||||
},
|
||||
});
|
||||
replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the issue in your next message.`;
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
// Send the reply
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (linkedNumber && driver.phone) {
|
||||
const formattedPhone = this.signalService.formatPhoneNumber(driver.phone);
|
||||
await this.signalService.sendMessage(linkedNumber, formattedPhone, replyMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send reply to driver:', error);
|
||||
}
|
||||
|
||||
return replyMessage;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { EventsController } from './events.controller';
|
||||
import { EventsService } from './events.service';
|
||||
import { EventStatusService } from './event-status.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SignalModule } from '../signal/signal.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
forwardRef(() => SignalModule), // forwardRef to avoid circular dependency
|
||||
],
|
||||
controllers: [
|
||||
EventsController,
|
||||
],
|
||||
providers: [
|
||||
EventsService,
|
||||
EventStatusService,
|
||||
],
|
||||
exports: [
|
||||
EventsService,
|
||||
EventStatusService,
|
||||
],
|
||||
})
|
||||
export class EventsModule {}
|
||||
|
||||
@@ -300,10 +300,11 @@ export class EventsService {
|
||||
|
||||
/**
|
||||
* Enrich event with VIP details fetched separately
|
||||
* Returns both `vips` array and `vip` (first VIP) for backwards compatibility
|
||||
*/
|
||||
private async enrichEventWithVips(event: any) {
|
||||
if (!event.vipIds || event.vipIds.length === 0) {
|
||||
return { ...event, vips: [] };
|
||||
return { ...event, vips: [], vip: null };
|
||||
}
|
||||
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
@@ -313,6 +314,7 @@ export class EventsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { ...event, vips };
|
||||
// Return both vips array and vip (first one) for backwards compatibility
|
||||
return { ...event, vips, vip: vips[0] || null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
3
backend/src/seed/index.ts
Normal file
3
backend/src/seed/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './seed.module';
|
||||
export * from './seed.service';
|
||||
export * from './seed.controller';
|
||||
36
backend/src/seed/seed.controller.ts
Normal file
36
backend/src/seed/seed.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
12
backend/src/seed/seed.module.ts
Normal file
12
backend/src/seed/seed.module.ts
Normal 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 {}
|
||||
626
backend/src/seed/seed.service.ts
Normal file
626
backend/src/seed/seed.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
105
backend/src/settings/dto/update-pdf-settings.dto.ts
Normal file
105
backend/src/settings/dto/update-pdf-settings.dto.ts
Normal 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;
|
||||
}
|
||||
61
backend/src/settings/settings.controller.ts
Normal file
61
backend/src/settings/settings.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
13
backend/src/settings/settings.module.ts
Normal file
13
backend/src/settings/settings.module.ts
Normal 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 {}
|
||||
139
backend/src/settings/settings.service.ts
Normal file
139
backend/src/settings/settings.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
200
backend/src/signal/messages.controller.ts
Normal file
200
backend/src/signal/messages.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
432
backend/src/signal/messages.service.ts
Normal file
432
backend/src/signal/messages.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
115
backend/src/signal/signal-polling.service.ts
Normal file
115
backend/src/signal/signal-polling.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
150
backend/src/signal/signal.controller.ts
Normal file
150
backend/src/signal/signal.controller.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
15
backend/src/signal/signal.module.ts
Normal file
15
backend/src/signal/signal.module.ts
Normal 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 {}
|
||||
327
backend/src/signal/signal.service.ts
Normal file
327
backend/src/signal/signal.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user