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:
2026-02-01 19:27:13 +01:00
parent 374ffcfa12
commit 2d842ed294
4 changed files with 4107 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

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

View 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}`,
};
}
}