This commit implements comprehensive driver schedule self-service functionality, allowing drivers to access their own schedules without requiring administrator permissions, along with full schedule support for multi-day views. Backend Changes: - Added /drivers/me/* endpoints for driver self-service operations: - GET /drivers/me - Get authenticated driver's profile - GET /drivers/me/schedule/ics - Export driver's own schedule as ICS - GET /drivers/me/schedule/pdf - Export driver's own schedule as PDF - POST /drivers/me/send-schedule - Send schedule to driver via Signal - PATCH /drivers/me - Update driver's own profile - Added fullSchedule parameter support to schedule export service: - Defaults to true (full upcoming schedule) - Pass fullSchedule=false for single-day view - Applied to ICS, PDF, and Signal message generation - Fixed route ordering in drivers.controller.ts: - Static routes (send-all-schedules) now come before :id routes - Prevents path matching issues - TypeScript improvements in copilot.service.ts: - Fixed type errors with proper null handling - Added explicit return types Frontend Changes: - Created MySchedule page with simplified driver-focused UI: - Preview PDF button - Opens schedule PDF in new browser tab - Send to Signal button - Sends schedule directly to driver's phone - Uses /drivers/me/* endpoints to avoid permission issues - No longer requires driver ID parameter - Resolved "Forbidden Resource" errors for driver role users: - Replaced /drivers/:id endpoints with /drivers/me endpoints - Drivers can now access their own data without admin permissions Key Features: 1. Full Schedule by Default - Drivers see all upcoming events, not just today 2. Self-Service Access - Drivers manage their own schedules independently 3. PDF Preview - Quick browser-based preview without downloading 4. Signal Integration - Direct schedule delivery to mobile devices 5. Role-Based Security - Proper CASL permissions for driver self-access This resolves the driver schedule access issue and provides a streamlined experience for drivers to view and share their schedules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
466 lines
13 KiB
TypeScript
466 lines
13 KiB
TypeScript
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<ScheduleEventWithDetails[]> {
|
|
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<ScheduleEventWithDetails[]> {
|
|
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<ScheduleEventWithDetails[]> {
|
|
// 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<string> {
|
|
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<Buffer> {
|
|
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}`,
|
|
};
|
|
}
|
|
}
|