feat: add driver schedule self-service and full schedule support
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>
This commit is contained in:
3152
backend/src/copilot/copilot.service.ts
Normal file
3152
backend/src/copilot/copilot.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,18 +8,24 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DriversService } from './drivers.service';
|
||||
import { ScheduleExportService } from './schedule-export.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Role } from '@prisma/client';
|
||||
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
||||
|
||||
@Controller('drivers')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class DriversController {
|
||||
constructor(private readonly driversService: DriversService) {}
|
||||
constructor(
|
||||
private readonly driversService: DriversService,
|
||||
private readonly scheduleExportService: ScheduleExportService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
@@ -33,6 +39,150 @@ export class DriversController {
|
||||
return this.driversService.findAll();
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
async getMyDriverProfile(@CurrentUser() user: any) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
return driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ICS calendar file for driver's own schedule
|
||||
* By default, returns full upcoming schedule. Pass fullSchedule=false for single day.
|
||||
*/
|
||||
@Get('me/schedule/ics')
|
||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
async getMyScheduleICS(
|
||||
@CurrentUser() user: any,
|
||||
@Query('date') dateStr?: string,
|
||||
@Query('fullSchedule') fullScheduleStr?: string,
|
||||
) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
const date = dateStr ? new Date(dateStr) : new Date();
|
||||
// Default to full schedule (true) unless explicitly set to false
|
||||
const fullSchedule = fullScheduleStr !== 'false';
|
||||
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
|
||||
const filename = fullSchedule
|
||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
|
||||
: `schedule-${date.toISOString().split('T')[0]}.ics`;
|
||||
return { ics: icsContent, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF schedule for driver's own schedule
|
||||
* By default, returns full upcoming schedule. Pass fullSchedule=false for single day.
|
||||
*/
|
||||
@Get('me/schedule/pdf')
|
||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
async getMySchedulePDF(
|
||||
@CurrentUser() user: any,
|
||||
@Query('date') dateStr?: string,
|
||||
@Query('fullSchedule') fullScheduleStr?: string,
|
||||
) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
const date = dateStr ? new Date(dateStr) : new Date();
|
||||
// Default to full schedule (true) unless explicitly set to false
|
||||
const fullSchedule = fullScheduleStr !== 'false';
|
||||
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
|
||||
const filename = fullSchedule
|
||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
|
||||
return { pdf: pdfBuffer.toString('base64'), filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send schedule to driver's own phone via Signal
|
||||
* By default, sends full upcoming schedule. Pass fullSchedule=false for single day.
|
||||
*/
|
||||
@Post('me/send-schedule')
|
||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
async sendMySchedule(
|
||||
@CurrentUser() user: any,
|
||||
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean },
|
||||
) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
const date = body.date ? new Date(body.date) : new Date();
|
||||
const format = body.format || 'both';
|
||||
// Default to full schedule (true) unless explicitly set to false
|
||||
const fullSchedule = body.fullSchedule !== false;
|
||||
return this.scheduleExportService.sendScheduleToDriver(driver.id, date, format, fullSchedule);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@Roles(Role.DRIVER)
|
||||
async updateMyProfile(@CurrentUser() user: any, @Body() updateDriverDto: UpdateDriverDto) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
return this.driversService.update(driver.id, updateDriverDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send schedule to all drivers with events on a given date
|
||||
* NOTE: This static route MUST come before :id routes to avoid matching issues
|
||||
*/
|
||||
@Post('send-all-schedules')
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
async sendAllSchedules(
|
||||
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both' },
|
||||
) {
|
||||
const date = body.date ? new Date(body.date) : new Date();
|
||||
const format = body.format || 'both';
|
||||
|
||||
// Get all drivers with events on this date
|
||||
const drivers = await this.driversService.findAll();
|
||||
const results: Array<{ driverId: string; driverName: string; success: boolean; message: string }> = [];
|
||||
|
||||
for (const driver of drivers) {
|
||||
try {
|
||||
const result = await this.scheduleExportService.sendScheduleToDriver(
|
||||
driver.id,
|
||||
date,
|
||||
format,
|
||||
);
|
||||
results.push({
|
||||
driverId: driver.id,
|
||||
driverName: driver.name,
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Skip drivers without events or phone numbers
|
||||
if (!error.message?.includes('No events')) {
|
||||
results.push({
|
||||
driverId: driver.id,
|
||||
driverName: driver.name,
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
return {
|
||||
success: true,
|
||||
sent: successCount,
|
||||
total: results.length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
// === Routes with :id parameter MUST come AFTER all static routes ===
|
||||
|
||||
@Get(':id')
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR, Role.DRIVER)
|
||||
findOne(@Param('id') id: string) {
|
||||
@@ -45,6 +195,20 @@ export class DriversController {
|
||||
return this.driversService.getSchedule(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send schedule to driver via Signal (ICS and/or PDF)
|
||||
*/
|
||||
@Post(':id/send-schedule')
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
async sendSchedule(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both' },
|
||||
) {
|
||||
const date = body.date ? new Date(body.date) : new Date();
|
||||
const format = body.format || 'both';
|
||||
return this.scheduleExportService.sendScheduleToDriver(id, date, format);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
update(@Param('id') id: string, @Body() updateDriverDto: UpdateDriverDto) {
|
||||
|
||||
465
backend/src/drivers/schedule-export.service.ts
Normal file
465
backend/src/drivers/schedule-export.service.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user