import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { SignalService } from '../signal/signal.service'; import * as ics from 'ics'; import * as PDFDocument from 'pdfkit'; interface ScheduleEventWithDetails { id: string; title: string; startTime: Date; endTime: Date; pickupLocation: string | null; dropoffLocation: string | null; location: string | null; notes: string | null; type: string; status: string; vipIds: string[]; vipNames: string[]; vehicle: { name: string; licensePlate: string | null } | null; } @Injectable() export class ScheduleExportService { private readonly logger = new Logger(ScheduleExportService.name); constructor( private readonly prisma: PrismaService, private readonly signalService: SignalService, ) {} /** * Get a driver's schedule for a specific date */ async getDriverSchedule( driverId: string, date: Date, ): Promise { const startOfDay = new Date(date); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(date); endOfDay.setHours(23, 59, 59, 999); const events = await this.prisma.scheduleEvent.findMany({ where: { driverId, deletedAt: null, startTime: { gte: startOfDay, lte: endOfDay, }, status: { not: 'CANCELLED', }, }, include: { vehicle: { select: { name: true, licensePlate: true }, }, }, orderBy: { startTime: 'asc' }, }); return this.mapEventsWithVipNames(events); } /** * Get a driver's full upcoming schedule (all future events) */ async getDriverFullSchedule( driverId: string, ): Promise { const now = new Date(); now.setHours(0, 0, 0, 0); // Start of today const events = await this.prisma.scheduleEvent.findMany({ where: { driverId, deletedAt: null, endTime: { gte: now, // Include events that haven't ended yet }, status: { not: 'CANCELLED', }, }, include: { vehicle: { select: { name: true, licensePlate: true }, }, }, orderBy: { startTime: 'asc' }, }); return this.mapEventsWithVipNames(events); } /** * Helper to map events with VIP names */ private async mapEventsWithVipNames( events: any[], ): Promise { // Fetch VIP names for all events const allVipIds = [...new Set(events.flatMap((e) => e.vipIds))]; const vips = await this.prisma.vIP.findMany({ where: { id: { in: allVipIds } }, select: { id: true, name: true }, }); const vipMap = new Map(vips.map((v) => [v.id, v.name])); // Map events with VIP names return events.map((event) => ({ id: event.id, title: event.title, startTime: event.startTime, endTime: event.endTime, pickupLocation: event.pickupLocation, dropoffLocation: event.dropoffLocation, location: event.location, notes: event.notes, type: event.type, status: event.status, vipIds: event.vipIds, vipNames: event.vipIds.map((id: string) => vipMap.get(id) || 'Unknown VIP'), vehicle: event.vehicle, })); } /** * Generate ICS calendar file for a driver's schedule * @param fullSchedule If true, includes all upcoming events. If false, only the specified date. */ async generateICS(driverId: string, date: Date, fullSchedule = false): Promise { const driver = await this.prisma.driver.findFirst({ where: { id: driverId, deletedAt: null }, }); if (!driver) { throw new NotFoundException(`Driver with ID ${driverId} not found`); } const events = fullSchedule ? await this.getDriverFullSchedule(driverId) : await this.getDriverSchedule(driverId, date); if (events.length === 0) { throw new NotFoundException(fullSchedule ? 'No upcoming events scheduled' : 'No events scheduled for this date'); } const icsEvents: ics.EventAttributes[] = events.map((event) => { const start = new Date(event.startTime); const end = new Date(event.endTime); const vipNames = event.vipNames.join(', '); const location = event.pickupLocation && event.dropoffLocation ? `${event.pickupLocation} → ${event.dropoffLocation}` : event.location || 'TBD'; let description = `VIP: ${vipNames}\n`; if (event.vehicle) { description += `Vehicle: ${event.vehicle.name}`; if (event.vehicle.licensePlate) { description += ` (${event.vehicle.licensePlate})`; } description += '\n'; } if (event.notes) { description += `Notes: ${event.notes}\n`; } return { start: [ start.getFullYear(), start.getMonth() + 1, start.getDate(), start.getHours(), start.getMinutes(), ] as [number, number, number, number, number], end: [ end.getFullYear(), end.getMonth() + 1, end.getDate(), end.getHours(), end.getMinutes(), ] as [number, number, number, number, number], title: `${event.title} - ${vipNames}`, description, location, status: 'CONFIRMED' as const, busyStatus: 'BUSY' as const, organizer: { name: 'VIP Coordinator', email: 'noreply@vipcoordinator.app' }, }; }); const { error, value } = ics.createEvents(icsEvents); if (error) { this.logger.error('Failed to generate ICS:', error); throw new Error('Failed to generate calendar file'); } return value || ''; } /** * Generate PDF schedule for a driver * @param fullSchedule If true, includes all upcoming events. If false, only the specified date. */ async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise { const driver = await this.prisma.driver.findFirst({ where: { id: driverId, deletedAt: null }, }); if (!driver) { throw new NotFoundException(`Driver with ID ${driverId} not found`); } const events = fullSchedule ? await this.getDriverFullSchedule(driverId) : await this.getDriverSchedule(driverId, date); if (events.length === 0) { throw new NotFoundException(fullSchedule ? 'No upcoming events scheduled' : 'No events scheduled for this date'); } return new Promise((resolve, reject) => { const chunks: Buffer[] = []; const doc = new PDFDocument({ margin: 50, size: 'LETTER' }); doc.on('data', (chunk) => chunks.push(chunk)); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', reject); const dateStr = fullSchedule ? 'Full Upcoming Schedule' : date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); // Header doc .fontSize(24) .font('Helvetica-Bold') .text('VIP Coordinator', { align: 'center' }); doc.moveDown(0.5); doc .fontSize(16) .font('Helvetica') .text(`Driver Schedule: ${driver.name}`, { align: 'center' }); doc.fontSize(12).text(dateStr, { align: 'center' }); doc.moveDown(1); // Divider line doc .moveTo(50, doc.y) .lineTo(doc.page.width - 50, doc.y) .stroke(); doc.moveDown(1); // Events events.forEach((event, index) => { const startTime = new Date(event.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', }); const endTime = new Date(event.endTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', }); const vipNames = event.vipNames.join(', '); // Event header with time doc .fontSize(14) .font('Helvetica-Bold') .text(`${startTime} - ${endTime}`, { continued: false }); // Event title doc.fontSize(12).font('Helvetica-Bold').text(event.title); // VIP doc.fontSize(11).font('Helvetica').text(`VIP: ${vipNames}`); // Location if (event.pickupLocation && event.dropoffLocation) { doc.text(`Pickup: ${event.pickupLocation}`); doc.text(`Dropoff: ${event.dropoffLocation}`); } else if (event.location) { doc.text(`Location: ${event.location}`); } // Vehicle if (event.vehicle) { let vehicleText = `Vehicle: ${event.vehicle.name}`; if (event.vehicle.licensePlate) { vehicleText += ` (${event.vehicle.licensePlate})`; } doc.text(vehicleText); } // Notes if (event.notes) { doc .fontSize(10) .fillColor('#666666') .text(`Notes: ${event.notes}`) .fillColor('#000000'); } // Status badge doc .fontSize(9) .fillColor(event.status === 'COMPLETED' ? '#22c55e' : '#3b82f6') .text(`Status: ${event.status}`) .fillColor('#000000'); // Spacing between events if (index < events.length - 1) { doc.moveDown(0.5); doc .moveTo(50, doc.y) .lineTo(doc.page.width - 50, doc.y) .strokeColor('#cccccc') .stroke() .strokeColor('#000000'); doc.moveDown(0.5); } }); // Footer doc.moveDown(2); doc .fontSize(9) .fillColor('#999999') .text( `Generated on ${new Date().toLocaleString('en-US')} by VIP Coordinator`, { align: 'center' }, ); doc.end(); }); } /** * Send schedule to driver via Signal * @param fullSchedule If true, sends all upcoming events. If false, only the specified date. */ async sendScheduleToDriver( driverId: string, date: Date, format: 'ics' | 'pdf' | 'both' = 'both', fullSchedule = false, ): Promise<{ success: boolean; message: string }> { const driver = await this.prisma.driver.findFirst({ where: { id: driverId, deletedAt: null }, }); if (!driver) { throw new NotFoundException(`Driver with ID ${driverId} not found`); } if (!driver.phone) { throw new Error('Driver does not have a phone number configured'); } const fromNumber = await this.signalService.getLinkedNumber(); if (!fromNumber) { throw new Error('No Signal account linked'); } const toNumber = this.signalService.formatPhoneNumber(driver.phone); const dateStr = fullSchedule ? 'your full upcoming schedule' : date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', }); const results: string[] = []; // Send text message first const events = fullSchedule ? await this.getDriverFullSchedule(driverId) : await this.getDriverSchedule(driverId, date); if (events.length === 0) { await this.signalService.sendMessage( fromNumber, toNumber, fullSchedule ? 'No upcoming events scheduled.' : `No events scheduled for ${dateStr}.`, ); return { success: true, message: 'No events to send' }; } await this.signalService.sendMessage( fromNumber, toNumber, `Your ${fullSchedule ? 'full upcoming' : ''} schedule${fullSchedule ? '' : ` for ${dateStr}`} (${events.length} event${events.length > 1 ? 's' : ''}):`, ); // Send ICS if (format === 'ics' || format === 'both') { try { const icsContent = await this.generateICS(driverId, date, fullSchedule); const icsBase64 = Buffer.from(icsContent).toString('base64'); const filename = fullSchedule ? `full-schedule-${new Date().toISOString().split('T')[0]}.ics` : `schedule-${date.toISOString().split('T')[0]}.ics`; await this.signalService.sendMessageWithAttachment( fromNumber, toNumber, 'Calendar file - add to your calendar app:', icsBase64, filename, 'text/calendar', ); results.push('ICS'); this.logger.log(`ICS sent to driver ${driver.name}`); } catch (error: any) { this.logger.error(`Failed to send ICS: ${error.message}`); } } // Send PDF if (format === 'pdf' || format === 'both') { try { const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule); const pdfBase64 = pdfBuffer.toString('base64'); const filename = fullSchedule ? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf` : `schedule-${date.toISOString().split('T')[0]}.pdf`; await this.signalService.sendMessageWithAttachment( fromNumber, toNumber, fullSchedule ? 'Full schedule PDF:' : 'PDF schedule:', pdfBase64, filename, 'application/pdf', ); results.push('PDF'); this.logger.log(`PDF sent to driver ${driver.name}`); } catch (error: any) { this.logger.error(`Failed to send PDF: ${error.message}`); } } if (results.length === 0) { throw new Error('Failed to send any schedule files'); } return { success: true, message: `Sent ${results.join(' and ')} schedule to ${driver.name}`, }; } }