refactor: code efficiency improvements (Issues #9-13, #15, #17-20)

Backend:
- Extract shared hard-delete authorization utility (#9)
- Extract Prisma include constants per entity (#11)
- Fix N+1 query pattern in events findAll (#12)
- Extract shared date utility functions (#13)
- Move vehicle utilization filtering to DB query (#15)
- Add ParseBooleanPipe for query params
- Add CurrentDriver decorator + ResolveDriverInterceptor (#20)

Frontend:
- Extract shared form utilities (toDatetimeLocal) and enum labels (#17)
- Replace browser confirm() with styled ConfirmModal (#18)
- Add centralized query-keys.ts constants (#19)
- Clean up unused imports, add useMemo where needed (#19)
- Standardize filter button styling across list pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 16:07:19 +01:00
parent 806b67954e
commit f2b3f34a72
38 changed files with 1042 additions and 463 deletions

View File

@@ -0,0 +1 @@
export * from './parse-boolean.pipe';

View File

@@ -0,0 +1,49 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
/**
* Transforms query string values to proper booleans.
*
* Handles common boolean string representations:
* - 'true', '1', 'yes', 'on' → true
* - 'false', '0', 'no', 'off' → false
* - undefined, null, '' → false (default)
* - Any other value → BadRequestException
*
* @example
* ```typescript
* @Delete(':id')
* async remove(
* @Param('id') id: string,
* @Query('hard', ParseBooleanPipe) hard: boolean,
* ) {
* return this.service.remove(id, hard);
* }
* ```
*/
@Injectable()
export class ParseBooleanPipe implements PipeTransform<string | undefined, boolean> {
transform(value: string | undefined): boolean {
// Handle undefined, null, or empty string as false (default)
if (value === undefined || value === null || value === '') {
return false;
}
// Normalize to lowercase for comparison
const normalized = value.toLowerCase().trim();
// True values
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
// False values
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
// Invalid value
throw new BadRequestException(
`Invalid boolean value: "${value}". Expected: true, false, 1, 0, yes, no, on, off`,
);
}
}

View File

@@ -0,0 +1,99 @@
/**
* Date utility functions to consolidate common date manipulation patterns
* across the VIP Coordinator application.
*/
/**
* Converts a Date object to ISO date string format (YYYY-MM-DD).
* Replaces the repetitive pattern: date.toISOString().split('T')[0]
*
* @param date - The date to convert
* @returns ISO date string in YYYY-MM-DD format
*
* @example
* const dateStr = toDateString(new Date('2024-01-15T10:30:00Z'));
* // Returns: '2024-01-15'
*/
export function toDateString(date: Date): string {
return date.toISOString().split('T')[0];
}
/**
* Normalizes a Date object to the start of the day (00:00:00.000).
* Replaces the pattern: date.setHours(0, 0, 0, 0)
*
* @param date - The date to normalize
* @returns A new Date object set to the start of the day
*
* @example
* const dayStart = startOfDay(new Date('2024-01-15T15:45:30Z'));
* // Returns: Date object at 2024-01-15T00:00:00.000
*/
export function startOfDay(date: Date): Date {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
return normalized;
}
/**
* Normalizes a Date object to the end of the day (23:59:59.999).
*
* @param date - The date to normalize
* @returns A new Date object set to the end of the day
*
* @example
* const dayEnd = endOfDay(new Date('2024-01-15T10:30:00Z'));
* // Returns: Date object at 2024-01-15T23:59:59.999
*/
export function endOfDay(date: Date): Date {
const normalized = new Date(date);
normalized.setHours(23, 59, 59, 999);
return normalized;
}
/**
* Converts optional date string fields to Date objects for multiple fields at once.
* Useful for DTO to Prisma data transformation where only provided fields should be converted.
*
* @param obj - The object containing date string fields
* @param fields - Array of field names that should be converted to Date objects if present
* @returns New object with specified fields converted to Date objects
*
* @example
* const dto = {
* name: 'Flight 123',
* scheduledDeparture: '2024-01-15T10:00:00Z',
* scheduledArrival: '2024-01-15T12:00:00Z',
* actualDeparture: undefined,
* };
*
* const data = convertOptionalDates(dto, [
* 'scheduledDeparture',
* 'scheduledArrival',
* 'actualDeparture',
* 'actualArrival'
* ]);
*
* // Result: {
* // name: 'Flight 123',
* // scheduledDeparture: Date object,
* // scheduledArrival: Date object,
* // actualDeparture: undefined,
* // actualArrival: undefined
* // }
*/
export function convertOptionalDates<T extends Record<string, any>>(
obj: T,
fields: (keyof T)[],
): T {
const result = { ...obj };
for (const field of fields) {
const value = obj[field];
if (value !== undefined && value !== null) {
result[field] = new Date(value as any) as any;
}
}
return result;
}

View File

@@ -0,0 +1,78 @@
import { ForbiddenException, Logger } from '@nestjs/common';
/**
* Enforces hard-delete authorization and executes the appropriate delete operation.
*
* @param options Configuration object
* @param options.id Entity ID to delete
* @param options.hardDelete Whether to perform hard delete (true) or soft delete (false)
* @param options.userRole User's role (required for hard delete authorization)
* @param options.findOne Function to find and verify entity exists
* @param options.performHardDelete Function to perform hard delete (e.g., prisma.model.delete)
* @param options.performSoftDelete Function to perform soft delete (e.g., prisma.model.update)
* @param options.entityName Name of entity for logging (e.g., 'VIP', 'Driver')
* @param options.logger Logger instance for the service
* @returns Promise resolving to the deleted entity
* @throws {ForbiddenException} If non-admin attempts hard delete
*
* @example
* ```typescript
* async remove(id: string, hardDelete = false, userRole?: string) {
* return executeHardDelete({
* id,
* hardDelete,
* userRole,
* findOne: async (id) => this.findOne(id),
* performHardDelete: async (id) => this.prisma.vIP.delete({ where: { id } }),
* performSoftDelete: async (id) => this.prisma.vIP.update({
* where: { id },
* data: { deletedAt: new Date() },
* }),
* entityName: 'VIP',
* logger: this.logger,
* });
* }
* ```
*/
export async function executeHardDelete<T>(options: {
id: string;
hardDelete: boolean;
userRole?: string;
findOne: (id: string) => Promise<T & { id: string; name?: string }>;
performHardDelete: (id: string) => Promise<any>;
performSoftDelete: (id: string) => Promise<any>;
entityName: string;
logger: Logger;
}): Promise<any> {
const {
id,
hardDelete,
userRole,
findOne,
performHardDelete,
performSoftDelete,
entityName,
logger,
} = options;
// Authorization check: only administrators can hard delete
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException(
'Only administrators can permanently delete records',
);
}
// Verify entity exists
const entity = await findOne(id);
// Perform the appropriate delete operation
if (hardDelete) {
const entityLabel = entity.name || entity.id;
logger.log(`Hard deleting ${entityName}: ${entityLabel}`);
return performHardDelete(entity.id);
}
const entityLabel = entity.name || entity.id;
logger.log(`Soft deleting ${entityName}: ${entityLabel}`);
return performSoftDelete(entity.id);
}

View File

@@ -0,0 +1,7 @@
/**
* Common utility functions used throughout the application.
* Export all utilities from this central location for easier imports.
*/
export * from './date.utils';
export * from './hard-delete.utils';

View File

@@ -3,6 +3,7 @@ import Anthropic from '@anthropic-ai/sdk';
import { PrismaService } from '../prisma/prisma.service';
import { MessagesService } from '../signal/messages.service';
import { ScheduleExportService } from '../drivers/schedule-export.service';
import { toDateString, startOfDay } from '../common/utils/date.utils';
interface ChatMessage {
role: 'user' | 'assistant';
@@ -609,7 +610,7 @@ export class CopilotService {
}
private buildSystemPrompt(userRole: string): string {
const today = new Date().toISOString().split('T')[0];
const today = toDateString(new Date());
return `You are an AI administrative assistant for VIP Coordinator, a transportation and event logistics application. You help coordinators manage VIP guests, drivers, vehicles, and events.
@@ -1174,8 +1175,7 @@ User role: ${userRole}
}
private async getTodaysSummary(): Promise<ToolResult> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const today = startOfDay(new Date());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
@@ -1776,17 +1776,16 @@ User role: ${userRole}
// Parse date or default to today
const targetDate = date ? new Date(date) : new Date();
const startOfDay = new Date(targetDate);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(targetDate);
endOfDay.setHours(23, 59, 59, 999);
const dayStart = startOfDay(targetDate);
const dayEnd = new Date(targetDate);
dayEnd.setHours(23, 59, 59, 999);
// Get events for this day
const events = await this.prisma.scheduleEvent.findMany({
where: {
driverId: driver.id,
deletedAt: null,
startTime: { gte: startOfDay, lte: endOfDay },
startTime: { gte: dayStart, lte: dayEnd },
status: { not: 'CANCELLED' },
},
include: {
@@ -1851,7 +1850,7 @@ User role: ${userRole}
shiftStartTime: driver.shiftStartTime,
shiftEndTime: driver.shiftEndTime,
},
date: targetDate.toISOString().split('T')[0],
date: toDateString(targetDate),
eventCount: events.length,
manifest,
},
@@ -1933,16 +1932,15 @@ User role: ${userRole}
const { date, driverNames } = input;
const targetDate = new Date(date);
const startOfDay = new Date(targetDate);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(targetDate);
endOfDay.setHours(23, 59, 59, 999);
const dayStart = startOfDay(targetDate);
const dayEnd = new Date(targetDate);
dayEnd.setHours(23, 59, 59, 999);
// Get all drivers with events on this date
const eventsOnDate = await this.prisma.scheduleEvent.findMany({
where: {
deletedAt: null,
startTime: { gte: startOfDay, lte: endOfDay },
startTime: { gte: dayStart, lte: dayEnd },
status: { not: 'CANCELLED' },
driverId: { not: null },
},
@@ -1998,7 +1996,7 @@ User role: ${userRole}
return {
success: true,
data: {
date: targetDate.toISOString().split('T')[0],
date: toDateString(targetDate),
totalDrivers: targetDrivers.length,
successful: results.length,
failed: errors.length,
@@ -2179,8 +2177,7 @@ User role: ${userRole}
private async getWeeklyLookahead(input: Record<string, any>): Promise<ToolResult> {
const { startDate, weeksAhead = 1 } = input;
const start = startDate ? new Date(startDate) : new Date();
start.setHours(0, 0, 0, 0);
const start = startOfDay(startDate ? new Date(startDate) : new Date());
const end = new Date(start);
end.setDate(end.getDate() + (weeksAhead * 7));
@@ -2227,9 +2224,8 @@ User role: ${userRole}
const dayMap = new Map<string, any>();
for (let d = new Date(start); d < end; d.setDate(d.getDate() + 1)) {
const dateKey = d.toISOString().split('T')[0];
const dayStart = new Date(d);
dayStart.setHours(0, 0, 0, 0);
const dateKey = toDateString(d);
const dayStart = startOfDay(d);
const dayEnd = new Date(d);
dayEnd.setHours(23, 59, 59, 999);
@@ -2257,8 +2253,8 @@ User role: ${userRole}
return {
success: true,
data: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
startDate: toDateString(start),
endDate: toDateString(end),
weeksAhead,
days: Array.from(dayMap.values()),
summary: {
@@ -2273,8 +2269,7 @@ User role: ${userRole}
private async identifySchedulingGaps(input: Record<string, any>): Promise<ToolResult> {
const { lookaheadDays = 7 } = input;
const start = new Date();
start.setHours(0, 0, 0, 0);
const start = startOfDay(new Date());
const end = new Date(start);
end.setDate(end.getDate() + lookaheadDays);
@@ -2391,8 +2386,8 @@ User role: ${userRole}
success: true,
data: {
dateRange: {
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0],
start: toDateString(start),
end: toDateString(end),
},
totalEvents: events.length,
issues: {
@@ -2586,8 +2581,8 @@ User role: ${userRole}
status: vehicle.status,
},
dateRange: {
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0],
start: toDateString(start),
end: toDateString(end),
},
eventCount: events.length,
schedule: eventsWithDetails,
@@ -2631,7 +2626,7 @@ User role: ${userRole}
// Calculate total days in range
const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
const daysWorked = new Set(
events.map(e => e.startTime.toISOString().split('T')[0])
events.map(e => toDateString(e.startTime))
).size;
return {
@@ -2655,8 +2650,8 @@ User role: ${userRole}
success: true,
data: {
dateRange: {
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0],
start: toDateString(start),
end: toDateString(end),
},
totalDrivers: drivers.length,
workload: workloadSummary,
@@ -2674,8 +2669,7 @@ User role: ${userRole}
private async getCurrentSystemStatus(): Promise<ToolResult> {
const now = new Date();
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const today = startOfDay(now);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date(today);

View File

@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Parameter decorator that extracts the current driver from the request.
* Should be used in conjunction with @UseInterceptors(ResolveDriverInterceptor)
* to ensure the driver is pre-resolved and attached to the request.
*/
export const CurrentDriver = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.driver;
},
);

View File

@@ -0,0 +1 @@
export * from './current-driver.decorator';

View File

@@ -8,6 +8,7 @@ import {
Param,
Query,
UseGuards,
UseInterceptors,
NotFoundException,
} from '@nestjs/common';
import { DriversService } from './drivers.service';
@@ -16,8 +17,12 @@ 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 { CurrentDriver } from './decorators';
import { ResolveDriverInterceptor } from './interceptors';
import { Role } from '@prisma/client';
import { CreateDriverDto, UpdateDriverDto } from './dto';
import { toDateString } from '../common/utils/date.utils';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('drivers')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -41,11 +46,8 @@ export class DriversController {
@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');
}
@UseInterceptors(ResolveDriverInterceptor)
getMyDriverProfile(@CurrentDriver() driver: any) {
return driver;
}
@@ -55,22 +57,19 @@ export class DriversController {
*/
@Get('me/schedule/ics')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async getMyScheduleICS(
@CurrentUser() user: any,
@CurrentDriver() driver: 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`;
? `full-schedule-${toDateString(new Date())}.ics`
: `schedule-${toDateString(date)}.ics`;
return { ics: icsContent, filename };
}
@@ -80,22 +79,19 @@ export class DriversController {
*/
@Get('me/schedule/pdf')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async getMySchedulePDF(
@CurrentUser() user: any,
@CurrentDriver() driver: 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`;
? `full-schedule-${toDateString(new Date())}.pdf`
: `schedule-${toDateString(date)}.pdf`;
return { pdf: pdfBuffer.toString('base64'), filename };
}
@@ -105,14 +101,11 @@ export class DriversController {
*/
@Post('me/send-schedule')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async sendMySchedule(
@CurrentUser() user: any,
@CurrentDriver() driver: 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
@@ -122,11 +115,8 @@ export class DriversController {
@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');
}
@UseInterceptors(ResolveDriverInterceptor)
updateMyProfile(@CurrentDriver() driver: any, @Body() updateDriverDto: UpdateDriverDto) {
return this.driversService.update(driver.id, updateDriverDto);
}
@@ -219,10 +209,9 @@ export class DriversController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.driversService.remove(id, isHardDelete, user?.role);
return this.driversService.remove(id, hard, user?.role);
}
}

View File

@@ -1,11 +1,21 @@
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateDriverDto, UpdateDriverDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class DriversService {
private readonly logger = new Logger(DriversService.name);
private readonly driverInclude = {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' as const },
},
} as const;
constructor(private prisma: PrismaService) {}
async create(createDriverDto: CreateDriverDto) {
@@ -20,14 +30,7 @@ export class DriversService {
async findAll() {
return this.prisma.driver.findMany({
where: { deletedAt: null },
include: {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' },
},
},
include: this.driverInclude,
orderBy: { name: 'asc' },
});
}
@@ -35,14 +38,7 @@ export class DriversService {
async findOne(id: string) {
const driver = await this.prisma.driver.findFirst({
where: { id, deletedAt: null },
include: {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' },
},
},
include: this.driverInclude,
});
if (!driver) {
@@ -55,14 +51,7 @@ export class DriversService {
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' },
},
},
include: this.driverInclude,
});
}
@@ -79,23 +68,19 @@ export class DriversService {
}
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const driver = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting driver: ${driver.name}`);
return this.prisma.driver.delete({
where: { id: driver.id },
});
}
this.logger.log(`Soft deleting driver: ${driver.name}`);
return this.prisma.driver.update({
where: { id: driver.id },
data: { deletedAt: new Date() },
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.driver.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.driver.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Driver',
logger: this.logger,
});
}

View File

@@ -0,0 +1 @@
export * from './resolve-driver.interceptor';

View File

@@ -0,0 +1,40 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
NotFoundException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { DriversService } from '../drivers.service';
/**
* Interceptor that resolves the current driver from the authenticated user
* and attaches it to the request object for /me routes.
* This prevents multiple calls to findByUserId() in each route handler.
*/
@Injectable()
export class ResolveDriverInterceptor implements NestInterceptor {
constructor(private readonly driversService: DriversService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new NotFoundException('User not authenticated');
}
// Resolve driver from user ID and attach to request
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
// Attach driver to request for use in route handlers
request.driver = driver;
return next.handle();
}
}

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { SignalService } from '../signal/signal.service';
import * as ics from 'ics';
import * as PDFDocument from 'pdfkit';
import { toDateString, startOfDay } from '../common/utils/date.utils';
interface ScheduleEventWithDetails {
id: string;
@@ -36,8 +37,7 @@ export class ScheduleExportService {
driverId: string,
date: Date,
): Promise<ScheduleEventWithDetails[]> {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const dayStart = startOfDay(date);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
@@ -47,7 +47,7 @@ export class ScheduleExportService {
driverId,
deletedAt: null,
startTime: {
gte: startOfDay,
gte: dayStart,
lte: endOfDay,
},
status: {
@@ -71,8 +71,7 @@ export class ScheduleExportService {
async getDriverFullSchedule(
driverId: string,
): Promise<ScheduleEventWithDetails[]> {
const now = new Date();
now.setHours(0, 0, 0, 0); // Start of today
const now = startOfDay(new Date()); // Start of today
const events = await this.prisma.scheduleEvent.findMany({
where: {
@@ -411,8 +410,8 @@ export class ScheduleExportService {
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`;
? `full-schedule-${toDateString(new Date())}.ics`
: `schedule-${toDateString(date)}.ics`;
await this.signalService.sendMessageWithAttachment(
fromNumber,
@@ -435,8 +434,8 @@ export class ScheduleExportService {
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`;
? `full-schedule-${toDateString(new Date())}.pdf`
: `schedule-${toDateString(date)}.pdf`;
await this.signalService.sendMessageWithAttachment(
fromNumber,

View File

@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('events')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -59,10 +60,9 @@ export class EventsController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.eventsService.remove(id, isHardDelete, user?.role);
return this.eventsService.remove(id, hard, user?.role);
}
}

View File

@@ -7,11 +7,24 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class EventsService {
private readonly logger = new Logger(EventsService.name);
private readonly eventInclude = {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
} as const;
constructor(private prisma: PrismaService) {}
async create(createEventDto: CreateEventDto) {
@@ -69,17 +82,7 @@ export class EventsService {
startTime: new Date(createEventDto.startTime),
endTime: new Date(createEventDto.endTime),
},
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
include: this.eventInclude,
});
return this.enrichEventWithVips(event);
@@ -88,37 +91,46 @@ export class EventsService {
async findAll() {
const events = await this.prisma.scheduleEvent.findMany({
where: { deletedAt: null },
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
include: this.eventInclude,
orderBy: { startTime: 'asc' },
});
return Promise.all(events.map((event) => this.enrichEventWithVips(event)));
// Collect all unique VIP IDs from all events
const allVipIds = new Set<string>();
events.forEach((event) => {
event.vipIds?.forEach((vipId) => allVipIds.add(vipId));
});
// Fetch all VIPs in a single query (eliminates N+1)
const vipsMap = new Map();
if (allVipIds.size > 0) {
const vips = await this.prisma.vIP.findMany({
where: {
id: { in: Array.from(allVipIds) },
deletedAt: null,
},
});
vips.forEach((vip) => vipsMap.set(vip.id, vip));
}
// Enrich each event with its VIPs from the map (no additional queries)
return events.map((event) => {
if (!event.vipIds || event.vipIds.length === 0) {
return { ...event, vips: [], vip: null };
}
const vips = event.vipIds
.map((vipId) => vipsMap.get(vipId))
.filter((vip) => vip !== undefined);
return { ...event, vips, vip: vips[0] || null };
});
}
async findOne(id: string) {
const event = await this.prisma.scheduleEvent.findFirst({
where: { id, deletedAt: null },
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
include: this.eventInclude,
});
if (!event) {
@@ -207,17 +219,7 @@ export class EventsService {
const updatedEvent = await this.prisma.scheduleEvent.update({
where: { id: event.id },
data: updateData,
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
include: this.eventInclude,
});
return this.enrichEventWithVips(updatedEvent);
@@ -233,40 +235,27 @@ export class EventsService {
const updatedEvent = await this.prisma.scheduleEvent.update({
where: { id: event.id },
data: { status: updateEventStatusDto.status },
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
include: this.eventInclude,
});
return this.enrichEventWithVips(updatedEvent);
}
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const event = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting event: ${event.title}`);
return this.prisma.scheduleEvent.delete({
where: { id: event.id },
});
}
this.logger.log(`Soft deleting event: ${event.title}`);
return this.prisma.scheduleEvent.update({
where: { id: event.id },
data: { deletedAt: new Date() },
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) =>
this.prisma.scheduleEvent.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.scheduleEvent.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Event',
logger: this.logger,
});
}

View File

@@ -5,6 +5,7 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { firstValueFrom } from 'rxjs';
import { Flight } from '@prisma/client';
import { toDateString } from '../common/utils/date.utils';
// Tracking phases - determines polling priority
const PHASE = {
@@ -319,7 +320,7 @@ export class FlightTrackingService {
private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise<Flight> {
const flightDate = flight.flightDate
? new Date(flight.flightDate).toISOString().split('T')[0]
? toDateString(new Date(flight.flightDate))
: undefined;
try {

View File

@@ -16,6 +16,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { CreateFlightDto, UpdateFlightDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('flights')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -88,9 +89,8 @@ export class FlightsController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
) {
const isHardDelete = hard === 'true';
return this.flightsService.remove(id, isHardDelete);
return this.flightsService.remove(id, hard);
}
}

View File

@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFlightDto, UpdateFlightDto } from './dto';
import { firstValueFrom } from 'rxjs';
import { convertOptionalDates } from '../common/utils/date.utils';
@Injectable()
export class FlightsService {
@@ -24,17 +25,16 @@ export class FlightsService {
`Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`,
);
return this.prisma.flight.create({
data: {
const data = convertOptionalDates(
{
...createFlightDto,
flightDate: new Date(createFlightDto.flightDate),
scheduledDeparture: createFlightDto.scheduledDeparture
? new Date(createFlightDto.scheduledDeparture)
: undefined,
scheduledArrival: createFlightDto.scheduledArrival
? new Date(createFlightDto.scheduledArrival)
: undefined,
},
['scheduledDeparture', 'scheduledArrival'],
);
return this.prisma.flight.create({
data,
include: { vip: true },
});
}
@@ -71,24 +71,13 @@ export class FlightsService {
this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`);
const updateData: any = { ...updateFlightDto };
const dto = updateFlightDto as any; // Type assertion to work around PartialType
if (dto.flightDate) {
updateData.flightDate = new Date(dto.flightDate);
}
if (dto.scheduledDeparture) {
updateData.scheduledDeparture = new Date(dto.scheduledDeparture);
}
if (dto.scheduledArrival) {
updateData.scheduledArrival = new Date(dto.scheduledArrival);
}
if (dto.actualDeparture) {
updateData.actualDeparture = new Date(dto.actualDeparture);
}
if (dto.actualArrival) {
updateData.actualArrival = new Date(dto.actualArrival);
}
const updateData = convertOptionalDates(updateFlightDto, [
'flightDate',
'scheduledDeparture',
'scheduledArrival',
'actualDeparture',
'actualArrival',
]);
return this.prisma.flight.update({
where: { id: flight.id },

View File

@@ -17,6 +17,7 @@ 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';
import { toDateString } from '../common/utils/date.utils';
// DTO for incoming Signal webhook
interface SignalWebhookPayload {
@@ -154,7 +155,7 @@ export class MessagesController {
async exportMessages(@Res() res: Response) {
const exportData = await this.messagesService.exportAllMessages();
const filename = `signal-chats-${new Date().toISOString().split('T')[0]}.txt`;
const filename = `signal-chats-${toDateString(new Date())}.txt`;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);

View File

@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('vehicles')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -59,10 +60,9 @@ export class VehiclesController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.vehiclesService.remove(id, isHardDelete, user?.role);
return this.vehiclesService.remove(id, hard, user?.role);
}
}

View File

@@ -1,11 +1,21 @@
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class VehiclesService {
private readonly logger = new Logger(VehiclesService.name);
private readonly vehicleInclude = {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' as const },
},
} as const;
constructor(private prisma: PrismaService) {}
async create(createVehicleDto: CreateVehicleDto) {
@@ -13,27 +23,14 @@ export class VehiclesService {
return this.prisma.vehicle.create({
data: createVehicleDto,
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
},
},
include: this.vehicleInclude,
});
}
async findAll() {
return this.prisma.vehicle.findMany({
where: { deletedAt: null },
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' },
},
},
include: this.vehicleInclude,
orderBy: { name: 'asc' },
});
}
@@ -54,14 +51,7 @@ export class VehiclesService {
async findOne(id: string) {
const vehicle = await this.prisma.vehicle.findFirst({
where: { id, deletedAt: null },
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' },
},
},
include: this.vehicleInclude,
});
if (!vehicle) {
@@ -79,34 +69,24 @@ export class VehiclesService {
return this.prisma.vehicle.update({
where: { id: vehicle.id },
data: updateVehicleDto,
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
},
},
include: this.vehicleInclude,
});
}
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const vehicle = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting vehicle: ${vehicle.name}`);
return this.prisma.vehicle.delete({
where: { id: vehicle.id },
});
}
this.logger.log(`Soft deleting vehicle: ${vehicle.name}`);
return this.prisma.vehicle.update({
where: { id: vehicle.id },
data: { deletedAt: new Date() },
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.vehicle.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.vehicle.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Vehicle',
logger: this.logger,
});
}
@@ -114,24 +94,35 @@ export class VehiclesService {
* Get vehicle utilization statistics
*/
async getUtilization() {
const vehicles = await this.findAll();
const now = new Date();
const stats = vehicles.map((vehicle) => {
const upcomingEvents = vehicle.events.filter(
(event) => new Date(event.startTime) > new Date(),
);
return {
id: vehicle.id,
name: vehicle.name,
type: vehicle.type,
seatCapacity: vehicle.seatCapacity,
status: vehicle.status,
upcomingTrips: upcomingEvents.length,
currentDriver: vehicle.currentDriver?.name,
};
// Fetch vehicles with only upcoming events (filtered at database level)
const vehicles = await this.prisma.vehicle.findMany({
where: { deletedAt: null },
include: {
currentDriver: true,
events: {
where: {
deletedAt: null,
startTime: { gt: now }, // Only fetch upcoming events
},
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' },
},
},
orderBy: { name: 'asc' },
});
const stats = vehicles.map((vehicle) => ({
id: vehicle.id,
name: vehicle.name,
type: vehicle.type,
seatCapacity: vehicle.seatCapacity,
status: vehicle.status,
upcomingTrips: vehicle.events.length, // Already filtered at DB level
currentDriver: vehicle.currentDriver?.name,
}));
return {
totalVehicles: vehicles.length,
available: vehicles.filter((v) => v.status === 'AVAILABLE').length,

View File

@@ -15,6 +15,7 @@ import { AbilitiesGuard } from '../auth/guards/abilities.guard';
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateVipDto, UpdateVipDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('vips')
@UseGuards(JwtAuthGuard, AbilitiesGuard)
@@ -49,11 +50,9 @@ export class VipsController {
@CanDelete('VIP')
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
// Only administrators can hard delete
const isHardDelete = hard === 'true';
return this.vipsService.remove(id, isHardDelete, user?.role);
return this.vipsService.remove(id, hard, user?.role);
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVipDto, UpdateVipDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class VipsService {
@@ -59,23 +60,19 @@ export class VipsService {
}
async remove(id: string, hardDelete = false, userRole?: string) {
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException('Only administrators can permanently delete records');
}
const vip = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting VIP: ${vip.name}`);
return this.prisma.vIP.delete({
where: { id: vip.id },
});
}
this.logger.log(`Soft deleting VIP: ${vip.name}`);
return this.prisma.vIP.update({
where: { id: vip.id },
data: { deletedAt: new Date() },
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.vIP.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.vIP.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'VIP',
logger: this.logger,
});
}
}