From 3bc9cd0bca44443a93d22a92f9f78777b7967294 Mon Sep 17 00:00:00 2001 From: kyle Date: Sun, 8 Feb 2026 16:34:18 +0100 Subject: [PATCH] refactor: complete code efficiency pass (Issues #10, #14, #16) Backend: - Add Prisma soft-delete middleware for automatic deletedAt filtering (#10) - Split 2758-line copilot.service.ts into focused sub-services (#14): - copilot-schedule.service.ts (schedule/event tools) - copilot-reports.service.ts (reporting/analytics tools) - copilot-fleet.service.ts (vehicle/driver tools) - copilot-vip.service.ts (VIP management tools) - copilot.service.ts now thin orchestrator - Remove manual deletedAt: null from 50+ queries Frontend: - Create SortableHeader component for reusable table sorting (#16) - Create useListPage hook for shared search/filter/sort state (#16) - Update VipList, DriverList, EventList to use shared infrastructure Co-Authored-By: Claude Opus 4.6 --- backend/src/auth/auth.service.ts | 10 +- backend/src/copilot/copilot-fleet.service.ts | 462 +++ .../src/copilot/copilot-reports.service.ts | 304 ++ .../src/copilot/copilot-schedule.service.ts | 1282 ++++++++ backend/src/copilot/copilot-vip.service.ts | 275 ++ backend/src/copilot/copilot.module.ts | 12 +- backend/src/copilot/copilot.service.ts | 2576 +++-------------- backend/src/drivers/drivers.service.ts | 6 +- .../src/drivers/schedule-export.service.ts | 8 +- backend/src/events/event-status.service.ts | 6 - backend/src/events/events.service.ts | 13 +- backend/src/gps/gps.service.ts | 4 - backend/src/prisma/prisma.service.ts | 54 + backend/src/signal/messages.service.ts | 6 +- backend/src/users/users.service.ts | 4 +- backend/src/vehicles/vehicles.service.ts | 7 +- backend/src/vips/vips.service.ts | 3 +- frontend/src/App.tsx | 2 +- frontend/src/components/SortableHeader.tsx | 36 + frontend/src/hooks/useListPage.ts | 55 + frontend/src/pages/DriverList.tsx | 84 +- frontend/src/pages/EventList.tsx | 109 +- frontend/src/pages/VipList.tsx | 100 +- 23 files changed, 2975 insertions(+), 2443 deletions(-) create mode 100644 backend/src/copilot/copilot-fleet.service.ts create mode 100644 backend/src/copilot/copilot-reports.service.ts create mode 100644 backend/src/copilot/copilot-schedule.service.ts create mode 100644 backend/src/copilot/copilot-vip.service.ts create mode 100644 frontend/src/components/SortableHeader.tsx create mode 100644 frontend/src/hooks/useListPage.ts diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index f2c3600..428731d 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -18,9 +18,9 @@ export class AuthService { const name = payload[`${namespace}/name`] || payload.name || 'Unknown User'; const picture = payload[`${namespace}/picture`] || payload.picture; - // Check if user exists (exclude soft-deleted users) + // Check if user exists (soft-deleted users automatically excluded by middleware) let user = await this.prisma.user.findFirst({ - where: { auth0Id, deletedAt: null }, + where: { auth0Id }, include: { driver: true }, }); @@ -29,7 +29,7 @@ export class AuthService { // where two simultaneous registrations both become admin user = await this.prisma.$transaction(async (tx) => { const approvedUserCount = await tx.user.count({ - where: { isApproved: true, deletedAt: null }, + where: { isApproved: true }, }); const isFirstUser = approvedUserCount === 0; @@ -63,11 +63,11 @@ export class AuthService { } /** - * Get current user profile (excludes soft-deleted users) + * Get current user profile (soft-deleted users automatically excluded by middleware) */ async getCurrentUser(auth0Id: string) { return this.prisma.user.findFirst({ - where: { auth0Id, deletedAt: null }, + where: { auth0Id }, include: { driver: true }, }); } diff --git a/backend/src/copilot/copilot-fleet.service.ts b/backend/src/copilot/copilot-fleet.service.ts new file mode 100644 index 0000000..ff31315 --- /dev/null +++ b/backend/src/copilot/copilot-fleet.service.ts @@ -0,0 +1,462 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { toDateString, startOfDay } from '../common/utils/date.utils'; + +interface ToolResult { + success: boolean; + data?: any; + error?: string; + message?: string; +} + +@Injectable() +export class CopilotFleetService { + private readonly logger = new Logger(CopilotFleetService.name); + + constructor(private readonly prisma: PrismaService) {} + + async getAvailableVehicles(filters: Record): Promise { + const where: any = { deletedAt: null, status: 'AVAILABLE' }; + + if (filters.type) { + where.type = filters.type; + } + + if (filters.minSeats) { + where.seatCapacity = { gte: filters.minSeats }; + } + + const vehicles = await this.prisma.vehicle.findMany({ + where, + orderBy: [{ type: 'asc' }, { seatCapacity: 'desc' }], + }); + + return { + success: true, + data: vehicles, + message: `Found ${vehicles.length} available vehicle(s).`, + }; + } + + async assignVehicleToEvent(eventId: string, vehicleId: string): Promise { + const event = await this.prisma.scheduleEvent.findFirst({ + where: { id: eventId, deletedAt: null }, + }); + + if (!event) { + return { success: false, error: `Event with ID ${eventId} not found.` }; + } + + // If vehicleId is null, we're unassigning + if (vehicleId === null || vehicleId === 'null') { + const updatedEvent = await this.prisma.scheduleEvent.update({ + where: { id: eventId }, + data: { vehicleId: null }, + include: { + driver: true, + }, + }); + + return { + success: true, + data: updatedEvent, + message: `Vehicle unassigned from event "${updatedEvent.title}"`, + }; + } + + // Verify vehicle exists + const vehicle = await this.prisma.vehicle.findFirst({ + where: { id: vehicleId, deletedAt: null }, + }); + + if (!vehicle) { + return { success: false, error: `Vehicle with ID ${vehicleId} not found.` }; + } + + const updatedEvent = await this.prisma.scheduleEvent.update({ + where: { id: eventId }, + data: { vehicleId }, + include: { + driver: true, + vehicle: true, + }, + }); + + return { + success: true, + data: updatedEvent, + message: `Vehicle ${vehicle.name} assigned to event "${updatedEvent.title}"`, + }; + } + + async suggestVehicleForEvent(input: Record): Promise { + const { eventId } = input; + + const event = await this.prisma.scheduleEvent.findFirst({ + where: { id: eventId, deletedAt: null }, + }); + + if (!event) { + return { success: false, error: `Event with ID ${eventId} not found.` }; + } + + // Fetch VIP info to determine party size + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: event.vipIds } }, + select: { id: true, name: true, partySize: true }, + }); + + // Determine required capacity based on total party size + const requiredSeats = vips.reduce((sum, v) => sum + (v.partySize || 1), 0); + + // Find vehicles not in use during this event time + const busyVehicleIds = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + id: { not: eventId }, + status: { not: 'CANCELLED' }, + vehicleId: { not: null }, + OR: [ + { + startTime: { lte: event.startTime }, + endTime: { gt: event.startTime }, + }, + { + startTime: { lt: event.endTime }, + endTime: { gte: event.endTime }, + }, + ], + }, + select: { vehicleId: true }, + }); + + const busyIds = busyVehicleIds.map((e) => e.vehicleId).filter((id): id is string => id !== null); + + // Find available vehicles with sufficient capacity + const suitableVehicles = await this.prisma.vehicle.findMany({ + where: { + deletedAt: null, + status: 'AVAILABLE', + seatCapacity: { gte: requiredSeats }, + id: { notIn: busyIds }, + }, + orderBy: [ + { seatCapacity: 'asc' }, // Prefer smallest suitable vehicle + ], + }); + + return { + success: true, + data: { + eventId, + eventTitle: event.title, + vipNames: vips.map((v) => v.name), + requiredSeats, + suggestions: suitableVehicles.map((v) => ({ + id: v.id, + name: v.name, + type: v.type, + seatCapacity: v.seatCapacity, + })), + }, + message: + suitableVehicles.length > 0 + ? `Found ${suitableVehicles.length} suitable vehicle(s) for this event (requires ${requiredSeats} seat(s)).` + : `No available vehicles found with capacity for ${requiredSeats} passenger(s) during this time.`, + }; + } + + async getVehicleSchedule(input: Record): Promise { + const { vehicleName, vehicleId, startDate, endDate } = input; + + let vehicle; + + if (vehicleId) { + vehicle = await this.prisma.vehicle.findFirst({ + where: { id: vehicleId, deletedAt: null }, + }); + } else if (vehicleName) { + const vehicles = await this.prisma.vehicle.findMany({ + where: { + deletedAt: null, + name: { contains: vehicleName, mode: 'insensitive' }, + }, + }); + + if (vehicles.length === 0) { + return { success: false, error: `No vehicle found matching "${vehicleName}".` }; + } + + if (vehicles.length > 1) { + return { + success: false, + error: `Multiple vehicles match "${vehicleName}": ${vehicles.map((v) => v.name).join(', ')}. Please be more specific.`, + }; + } + + vehicle = vehicles[0]; + } else { + return { success: false, error: 'Either vehicleName or vehicleId is required.' }; + } + + if (!vehicle) { + return { success: false, error: 'Vehicle not found.' }; + } + + const dateStart = startOfDay(new Date(startDate)); + const dateEnd = new Date(endDate); + dateEnd.setHours(23, 59, 59, 999); + + const events = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + vehicleId: vehicle.id, + startTime: { gte: dateStart, lte: dateEnd }, + status: { not: 'CANCELLED' }, + }, + include: { + driver: true, + }, + orderBy: { startTime: 'asc' }, + }); + + // Fetch VIP names for all events + const allVipIds = events.flatMap((e) => e.vipIds); + const uniqueVipIds = [...new Set(allVipIds)]; + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: uniqueVipIds } }, + select: { id: true, name: true }, + }); + const vipMap = new Map(vips.map((v) => [v.id, v.name])); + + const totalHours = + events.reduce((sum, e) => { + return sum + (e.endTime.getTime() - e.startTime.getTime()); + }, 0) / 3600000; + + return { + success: true, + data: { + vehicle: { + id: vehicle.id, + name: vehicle.name, + type: vehicle.type, + seatCapacity: vehicle.seatCapacity, + status: vehicle.status, + }, + dateRange: { + start: toDateString(dateStart), + end: toDateString(dateEnd), + }, + eventCount: events.length, + totalHours: Math.round(totalHours * 10) / 10, + events: events.map((e) => ({ + eventId: e.id, + title: e.title, + type: e.type, + startTime: e.startTime, + endTime: e.endTime, + vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + driverName: e.driver?.name || null, + pickupLocation: e.pickupLocation, + dropoffLocation: e.dropoffLocation, + location: e.location, + })), + }, + message: `Vehicle ${vehicle.name} has ${events.length} scheduled event(s) (${Math.round(totalHours * 10) / 10} hours total).`, + }; + } + + async searchDrivers(filters: Record): Promise { + const where: any = { deletedAt: null }; + + if (filters.name) { + where.name = { contains: filters.name, mode: 'insensitive' }; + } + + if (filters.department) { + where.department = filters.department; + } + + if (filters.availableOnly) { + where.isAvailable = true; + } + + const drivers = await this.prisma.driver.findMany({ + where, + orderBy: { name: 'asc' }, + }); + + return { + success: true, + data: drivers, + message: `Found ${drivers.length} driver(s) matching the criteria.`, + }; + } + + async getDriverSchedule( + driverId: string, + startDate?: string, + endDate?: string, + ): Promise { + const driver = await this.prisma.driver.findFirst({ + where: { id: driverId, deletedAt: null }, + }); + + if (!driver) { + return { success: false, error: `Driver with ID ${driverId} not found.` }; + } + + const where: any = { + deletedAt: null, + driverId, + status: { not: 'CANCELLED' }, + }; + + if (startDate) { + where.startTime = { gte: new Date(startDate) }; + } + + if (endDate) { + where.endTime = { lte: new Date(endDate) }; + } + + const events = await this.prisma.scheduleEvent.findMany({ + where, + include: { + vehicle: true, + }, + orderBy: { startTime: 'asc' }, + }); + + // Fetch VIP names for all events + const allVipIds = events.flatMap((e) => e.vipIds); + const uniqueVipIds = [...new Set(allVipIds)]; + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: uniqueVipIds } }, + select: { id: true, name: true }, + }); + const vipMap = new Map(vips.map((v) => [v.id, v.name])); + + const eventsWithVipNames = events.map((event) => ({ + ...event, + vipNames: event.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + })); + + return { + success: true, + data: { + driver, + events: eventsWithVipNames, + eventCount: events.length, + }, + message: `Driver ${driver.name} has ${events.length} scheduled event(s).`, + }; + } + + async listAllDrivers(input: Record): Promise { + const { includeUnavailable = true } = input; + + const where: any = { deletedAt: null }; + + if (!includeUnavailable) { + where.isAvailable = true; + } + + const drivers = await this.prisma.driver.findMany({ + where, + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + phone: true, + department: true, + isAvailable: true, + }, + }); + + return { + success: true, + data: drivers, + message: `Found ${drivers.length} driver(s) in the system.`, + }; + } + + async findAvailableDriversForTimerange(input: Record): Promise { + const { startTime, endTime, preferredDepartment } = input; + + // Get all drivers + const where: any = { deletedAt: null, isAvailable: true }; + + if (preferredDepartment) { + where.department = preferredDepartment; + } + + const allDrivers = await this.prisma.driver.findMany({ + where, + }); + + // Find drivers with conflicting events + const busyDriverIds = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + driverId: { not: null }, + status: { not: 'CANCELLED' }, + OR: [ + { + startTime: { lte: new Date(startTime) }, + endTime: { gt: new Date(startTime) }, + }, + { + startTime: { lt: new Date(endTime) }, + endTime: { gte: new Date(endTime) }, + }, + ], + }, + select: { driverId: true }, + }); + + const busyIds = new Set(busyDriverIds.map((e) => e.driverId)); + + const availableDrivers = allDrivers.filter((d) => !busyIds.has(d.id)); + + return { + success: true, + data: availableDrivers, + message: `Found ${availableDrivers.length} available driver(s) for the specified time range.`, + }; + } + + async updateDriver(input: Record): Promise { + const { driverId, ...updates } = input; + + const existingDriver = await this.prisma.driver.findFirst({ + where: { id: driverId, deletedAt: null }, + }); + + if (!existingDriver) { + return { success: false, error: `Driver with ID ${driverId} not found.` }; + } + + const updateData: any = {}; + if (updates.name !== undefined) updateData.name = updates.name; + if (updates.phone !== undefined) updateData.phone = updates.phone; + if (updates.department !== undefined) updateData.department = updates.department; + if (updates.isAvailable !== undefined) updateData.isAvailable = updates.isAvailable; + if (updates.shiftStartTime !== undefined) updateData.shiftStartTime = updates.shiftStartTime; + if (updates.shiftEndTime !== undefined) updateData.shiftEndTime = updates.shiftEndTime; + + const driver = await this.prisma.driver.update({ + where: { id: driverId }, + data: updateData, + }); + + this.logger.log(`Driver updated: ${driverId}`); + + return { + success: true, + data: driver, + message: `Driver ${driver.name} updated successfully.`, + }; + } +} diff --git a/backend/src/copilot/copilot-reports.service.ts b/backend/src/copilot/copilot-reports.service.ts new file mode 100644 index 0000000..7125cb8 --- /dev/null +++ b/backend/src/copilot/copilot-reports.service.ts @@ -0,0 +1,304 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { toDateString, startOfDay } from '../common/utils/date.utils'; + +interface ToolResult { + success: boolean; + data?: any; + error?: string; + message?: string; +} + +@Injectable() +export class CopilotReportsService { + private readonly logger = new Logger(CopilotReportsService.name); + + constructor(private readonly prisma: PrismaService) {} + + async getDriverWorkloadSummary(input: Record): Promise { + const { startDate, endDate } = input; + + const dateStart = startOfDay(new Date(startDate)); + const dateEnd = new Date(endDate); + dateEnd.setHours(23, 59, 59, 999); + + // Get all drivers + const drivers = await this.prisma.driver.findMany({ + where: { deletedAt: null }, + orderBy: { name: 'asc' }, + }); + + // Get all events in range + const events = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + startTime: { gte: dateStart, lte: dateEnd }, + status: { not: 'CANCELLED' }, + driverId: { not: null }, + }, + include: { + driver: true, + }, + }); + + // Calculate workload for each driver + const workloadData = drivers.map((driver) => { + const driverEvents = events.filter((e) => e.driverId === driver.id); + + const totalHours = + driverEvents.reduce((sum, e) => { + return sum + (e.endTime.getTime() - e.startTime.getTime()); + }, 0) / 3600000; + + const totalDays = Math.ceil( + (dateEnd.getTime() - dateStart.getTime()) / (1000 * 60 * 60 * 24), + ); + + const eventsByType = driverEvents.reduce( + (acc, e) => { + acc[e.type] = (acc[e.type] || 0) + 1; + return acc; + }, + {} as Record, + ); + + return { + driverId: driver.id, + driverName: driver.name, + department: driver.department, + isAvailable: driver.isAvailable, + eventCount: driverEvents.length, + totalHours: Math.round(totalHours * 10) / 10, + averageHoursPerDay: Math.round((totalHours / totalDays) * 10) / 10, + eventsByType, + }; + }); + + // Sort by total hours descending + workloadData.sort((a, b) => b.totalHours - a.totalHours); + + const totalEvents = events.length; + const totalHours = + events.reduce((sum, e) => { + return sum + (e.endTime.getTime() - e.startTime.getTime()); + }, 0) / 3600000; + + return { + success: true, + data: { + dateRange: { + start: toDateString(dateStart), + end: toDateString(dateEnd), + }, + summary: { + totalDrivers: drivers.length, + totalEvents, + totalHours: Math.round(totalHours * 10) / 10, + averageEventsPerDriver: Math.round((totalEvents / drivers.length) * 10) / 10, + }, + driverWorkloads: workloadData, + }, + message: `Workload summary for ${drivers.length} driver(s) from ${toDateString(dateStart)} to ${toDateString(dateEnd)}.`, + }; + } + + async getCurrentSystemStatus(): Promise { + const now = new Date(); + const today = startOfDay(now); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const nextWeek = new Date(today); + nextWeek.setDate(nextWeek.getDate() + 7); + + const [ + vipCount, + vehicleCount, + driverCount, + todaysEvents, + upcomingEvents, + unassignedEvents, + availableDrivers, + availableVehicles, + ] = await Promise.all([ + this.prisma.vIP.count({ where: { deletedAt: null } }), + this.prisma.vehicle.count({ where: { deletedAt: null } }), + this.prisma.driver.count({ where: { deletedAt: null } }), + this.prisma.scheduleEvent.count({ + where: { + deletedAt: null, + startTime: { gte: today, lt: tomorrow }, + status: { not: 'CANCELLED' }, + }, + }), + this.prisma.scheduleEvent.count({ + where: { + deletedAt: null, + startTime: { gte: tomorrow, lt: nextWeek }, + status: { not: 'CANCELLED' }, + }, + }), + this.prisma.scheduleEvent.count({ + where: { + deletedAt: null, + startTime: { gte: now }, + status: { in: ['SCHEDULED'] }, + OR: [{ driverId: null }, { vehicleId: null }], + }, + }), + this.prisma.driver.count({ where: { deletedAt: null, isAvailable: true } }), + this.prisma.vehicle.count({ where: { deletedAt: null, status: 'AVAILABLE' } }), + ]); + + const status = { + timestamp: now.toISOString(), + resources: { + vips: vipCount, + drivers: { total: driverCount, available: availableDrivers }, + vehicles: { total: vehicleCount, available: availableVehicles }, + }, + events: { + today: todaysEvents, + next7Days: upcomingEvents, + needingAttention: unassignedEvents, + }, + alerts: [] as string[], + }; + + // Add alerts for issues + if (unassignedEvents > 0) { + status.alerts.push(`${unassignedEvents} upcoming event(s) need driver/vehicle assignment`); + } + if (availableDrivers === 0) { + status.alerts.push('No drivers currently marked as available'); + } + if (availableVehicles === 0) { + status.alerts.push('No vehicles currently available'); + } + + return { + success: true, + data: status, + message: + status.alerts.length > 0 + ? `System status retrieved. ATTENTION: ${status.alerts.length} alert(s) require attention.` + : 'System status retrieved. No immediate issues.', + }; + } + + async getTodaysSummary(): Promise { + const today = startOfDay(new Date()); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + // Get today's events + const events = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + startTime: { gte: today, lt: tomorrow }, + status: { not: 'CANCELLED' }, + }, + include: { + driver: true, + vehicle: true, + }, + orderBy: { startTime: 'asc' }, + }); + + // Fetch VIP names for all events + const allVipIds = events.flatMap((e) => e.vipIds); + const uniqueVipIds = [...new Set(allVipIds)]; + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: uniqueVipIds } }, + select: { id: true, name: true }, + }); + const vipMap = new Map(vips.map((v) => [v.id, v.name])); + + // Get VIPs arriving today (flights or self-driving) + const arrivingVips = await this.prisma.vIP.findMany({ + where: { + deletedAt: null, + OR: [ + { + expectedArrival: { gte: today, lt: tomorrow }, + }, + { + flights: { + some: { + scheduledArrival: { gte: today, lt: tomorrow }, + }, + }, + }, + ], + }, + include: { + flights: { + where: { + scheduledArrival: { gte: today, lt: tomorrow }, + }, + orderBy: { scheduledArrival: 'asc' }, + }, + }, + }); + + // Get driver assignments + const driversOnDuty = events + .filter((e) => e.driver) + .reduce((acc, e) => { + if (e.driver && !acc.find((d) => d.id === e.driver!.id)) { + acc.push(e.driver); + } + return acc; + }, [] as NonNullable[]); + + // Unassigned events + const unassigned = events.filter((e) => !e.driverId || !e.vehicleId); + + return { + success: true, + data: { + date: toDateString(today), + summary: { + totalEvents: events.length, + arrivingVips: arrivingVips.length, + driversOnDuty: driversOnDuty.length, + unassignedEvents: unassigned.length, + }, + events: events.map((e) => ({ + id: e.id, + time: e.startTime, + title: e.title, + type: e.type, + vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + driverName: e.driver?.name || 'UNASSIGNED', + vehicleName: e.vehicle?.name || 'UNASSIGNED', + location: e.location || e.pickupLocation, + })), + arrivingVips: arrivingVips.map((v) => ({ + id: v.id, + name: v.name, + arrivalMode: v.arrivalMode, + expectedArrival: v.expectedArrival, + flights: v.flights.map((f) => ({ + flightNumber: f.flightNumber, + scheduledArrival: f.scheduledArrival, + arrivalAirport: f.arrivalAirport, + })), + })), + driversOnDuty: driversOnDuty.map((d) => ({ + id: d.id, + name: d.name, + eventCount: events.filter((e) => e.driverId === d.id).length, + })), + unassignedEvents: unassigned.map((e) => ({ + id: e.id, + time: e.startTime, + title: e.title, + vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + needsDriver: !e.driverId, + needsVehicle: !e.vehicleId, + })), + }, + message: `Today's summary: ${events.length} event(s), ${arrivingVips.length} VIP(s) arriving, ${unassigned.length} unassigned.`, + }; + } +} diff --git a/backend/src/copilot/copilot-schedule.service.ts b/backend/src/copilot/copilot-schedule.service.ts new file mode 100644 index 0000000..9b03ccb --- /dev/null +++ b/backend/src/copilot/copilot-schedule.service.ts @@ -0,0 +1,1282 @@ +import { Injectable, Logger } from '@nestjs/common'; +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 ToolResult { + success: boolean; + data?: any; + error?: string; + message?: string; +} + +@Injectable() +export class CopilotScheduleService { + private readonly logger = new Logger(CopilotScheduleService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly messagesService: MessagesService, + private readonly scheduleExportService: ScheduleExportService, + ) {} + + async searchEvents(filters: Record): Promise { + const where: any = { deletedAt: null }; + + // If searching by VIP name, first find matching VIPs + let vipIdsFromName: string[] = []; + if (filters.vipName) { + const matchingVips = await this.prisma.vIP.findMany({ + where: { + deletedAt: null, + name: { contains: filters.vipName, mode: 'insensitive' }, + }, + select: { id: true }, + }); + vipIdsFromName = matchingVips.map((v) => v.id); + if (vipIdsFromName.length === 0) { + return { + success: true, + data: [], + message: `No VIPs found matching "${filters.vipName}"`, + }; + } + } + + // If searching by driver name, first find matching drivers + let driverIdsFromName: string[] = []; + if (filters.driverName) { + const matchingDrivers = await this.prisma.driver.findMany({ + where: { + deletedAt: null, + name: { contains: filters.driverName, mode: 'insensitive' }, + }, + select: { id: true }, + }); + driverIdsFromName = matchingDrivers.map((d) => d.id); + if (driverIdsFromName.length === 0) { + return { + success: true, + data: [], + message: `No drivers found matching "${filters.driverName}"`, + }; + } + } + + if (filters.vipId) { + where.vipIds = { has: filters.vipId }; + } else if (vipIdsFromName.length > 0) { + where.vipIds = { hasSome: vipIdsFromName }; + } + + if (filters.title) { + where.title = { contains: filters.title, mode: 'insensitive' }; + } + + if (filters.driverId) { + where.driverId = filters.driverId; + } else if (driverIdsFromName.length > 0) { + where.driverId = { in: driverIdsFromName }; + } + + if (filters.date) { + const dateStart = startOfDay(new Date(filters.date)); + const dateEnd = new Date(dateStart); + dateEnd.setDate(dateEnd.getDate() + 1); + + where.startTime = { + gte: dateStart, + lt: dateEnd, + }; + } + + if (filters.status) { + where.status = filters.status; + } + + if (filters.type) { + where.type = filters.type; + } + + const events = await this.prisma.scheduleEvent.findMany({ + where, + include: { + driver: true, + vehicle: true, + }, + orderBy: { startTime: 'asc' }, + }); + + // Fetch VIP names for events + const allVipIds = events.flatMap((e) => e.vipIds); + const uniqueVipIds = [...new Set(allVipIds)]; + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: uniqueVipIds } }, + select: { id: true, name: true }, + }); + const vipMap = new Map(vips.map((v) => [v.id, v.name])); + + const eventsWithVipNames = events.map((event) => ({ + ...event, + vipNames: event.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + })); + + return { + success: true, + data: eventsWithVipNames, + message: `Found ${events.length} event(s) matching the criteria.`, + }; + } + + async createEvent(input: Record): Promise { + // Support both single vipId and array of vipIds + const vipIds = input.vipIds || (input.vipId ? [input.vipId] : []); + + if (vipIds.length === 0) { + return { success: false, error: 'At least one VIP ID is required.' }; + } + + const { + title, + type, + startTime, + endTime, + location, + pickupLocation, + dropoffLocation, + driverId, + vehicleId, + description, + } = input; + + // If driver is assigned, check for conflicts + if (driverId) { + const driverConflicts = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + driverId, + status: { not: 'CANCELLED' }, + OR: [ + { + startTime: { lte: new Date(startTime) }, + endTime: { gt: new Date(startTime) }, + }, + { + startTime: { lt: new Date(endTime) }, + endTime: { gte: new Date(endTime) }, + }, + ], + }, + }); + + if (driverConflicts.length > 0) { + return { + success: false, + error: `Driver has ${driverConflicts.length} conflicting event(s) during this time. First conflict: ${driverConflicts[0].title}`, + }; + } + } + + const event = await this.prisma.scheduleEvent.create({ + data: { + vipIds, + title, + type, + startTime: new Date(startTime), + endTime: new Date(endTime), + location, + pickupLocation, + dropoffLocation, + driverId, + vehicleId, + description, + status: 'SCHEDULED', + }, + include: { + driver: true, + vehicle: true, + }, + }); + + // Fetch VIP names + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: vipIds } }, + select: { id: true, name: true }, + }); + + this.logger.log(`Event created: ${event.id} - ${event.title}`); + + return { + success: true, + data: { ...event, vipNames: vips.map((v) => v.name) }, + message: `Event "${title}" created successfully for ${vips.map((v) => v.name).join(', ')}`, + }; + } + + async updateEvent(input: Record): Promise { + const { eventId, ...updates } = input; + + const existingEvent = await this.prisma.scheduleEvent.findFirst({ + where: { id: eventId, deletedAt: null }, + }); + + if (!existingEvent) { + return { success: false, error: `Event with ID ${eventId} not found.` }; + } + + // If updating driver and time, check for conflicts + const newDriverId = updates.driverId !== undefined ? updates.driverId : existingEvent.driverId; + const newStartTime = updates.startTime ? new Date(updates.startTime) : existingEvent.startTime; + const newEndTime = updates.endTime ? new Date(updates.endTime) : existingEvent.endTime; + + if (newDriverId) { + const driverConflicts = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + driverId: newDriverId, + id: { not: eventId }, + status: { not: 'CANCELLED' }, + OR: [ + { + startTime: { lte: newStartTime }, + endTime: { gt: newStartTime }, + }, + { + startTime: { lt: newEndTime }, + endTime: { gte: newEndTime }, + }, + ], + }, + include: {}, + }); + + if (driverConflicts.length > 0) { + return { + success: false, + error: `Driver has ${driverConflicts.length} conflicting event(s) during this time.`, + }; + } + } + + const updateData: any = {}; + if (updates.title !== undefined) updateData.title = updates.title; + if (updates.startTime !== undefined) updateData.startTime = new Date(updates.startTime); + if (updates.endTime !== undefined) updateData.endTime = new Date(updates.endTime); + if (updates.location !== undefined) updateData.location = updates.location; + if (updates.pickupLocation !== undefined) updateData.pickupLocation = updates.pickupLocation; + if (updates.dropoffLocation !== undefined) updateData.dropoffLocation = updates.dropoffLocation; + if (updates.status !== undefined) updateData.status = updates.status; + if (updates.driverId !== undefined) updateData.driverId = updates.driverId; + if (updates.vehicleId !== undefined) updateData.vehicleId = updates.vehicleId; + if (updates.description !== undefined) updateData.description = updates.description; + + const event = await this.prisma.scheduleEvent.update({ + where: { id: eventId }, + data: updateData, + include: { + driver: true, + vehicle: true, + }, + }); + + this.logger.log(`Event updated: ${eventId}`); + + return { + success: true, + data: event, + message: 'Event updated successfully', + }; + } + + async deleteEvent(eventId: string): Promise { + const existingEvent = await this.prisma.scheduleEvent.findFirst({ + where: { id: eventId, deletedAt: null }, + }); + + if (!existingEvent) { + return { success: false, error: `Event with ID ${eventId} not found.` }; + } + + // Fetch VIP names + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: existingEvent.vipIds } }, + select: { name: true }, + }); + const vipNames = vips.map((v) => v.name).join(', '); + + await this.prisma.scheduleEvent.update({ + where: { id: eventId }, + data: { deletedAt: new Date() }, + }); + + this.logger.log(`Event soft-deleted: ${eventId} - ${existingEvent.title}`); + + return { + success: true, + message: `Event "${existingEvent.title}" for ${vipNames} has been deleted.`, + }; + } + + async assignDriverToEvent(eventId: string, driverId: string): Promise { + const event = await this.prisma.scheduleEvent.findFirst({ + where: { id: eventId, deletedAt: null }, + }); + + if (!event) { + return { success: false, error: `Event with ID ${eventId} not found.` }; + } + + // Check for driver conflicts + const conflicts = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + driverId, + id: { not: eventId }, + status: { not: 'CANCELLED' }, + OR: [ + { + startTime: { lte: event.startTime }, + endTime: { gt: event.startTime }, + }, + { + startTime: { lt: event.endTime }, + endTime: { gte: event.endTime }, + }, + ], + }, + }); + + if (conflicts.length > 0) { + return { + success: false, + error: `Driver has ${conflicts.length} conflicting event(s) during this time slot.`, + }; + } + + const updatedEvent = await this.prisma.scheduleEvent.update({ + where: { id: eventId }, + data: { driverId }, + include: { + driver: true, + vehicle: true, + }, + }); + + return { + success: true, + data: updatedEvent, + message: `Driver ${updatedEvent.driver?.name || 'Unknown'} assigned to event "${updatedEvent.title}"`, + }; + } + + async checkDriverConflicts(input: Record): Promise { + const { driverId, startTime, endTime, excludeEventId } = input; + + const where: any = { + deletedAt: null, + driverId, + status: { not: 'CANCELLED' }, + OR: [ + { + startTime: { lte: new Date(startTime) }, + endTime: { gt: new Date(startTime) }, + }, + { + startTime: { lt: new Date(endTime) }, + endTime: { gte: new Date(endTime) }, + }, + ], + }; + + if (excludeEventId) { + where.id = { not: excludeEventId }; + } + + const conflicts = await this.prisma.scheduleEvent.findMany({ + where, + orderBy: { startTime: 'asc' }, + }); + + // Fetch VIP names for all conflicts + const vipIds = conflicts.flatMap((c) => c.vipIds); + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: vipIds }, deletedAt: null }, + select: { id: true, name: true }, + }); + const vipMap = new Map(vips.map((v) => [v.id, v.name])); + + return { + success: true, + data: { + hasConflicts: conflicts.length > 0, + conflictCount: conflicts.length, + conflicts: conflicts.map((c) => ({ + eventId: c.id, + title: c.title, + vipNames: c.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + startTime: c.startTime, + endTime: c.endTime, + })), + }, + message: + conflicts.length > 0 + ? `Driver has ${conflicts.length} conflict(s) during this time.` + : 'No conflicts found for this driver during the specified time.', + }; + } + + async reassignDriverEvents(input: Record): Promise { + const { fromDriverName, toDriverName, date, onlyStatus } = input; + + // Find the "from" driver + const fromDrivers = await this.prisma.driver.findMany({ + where: { + deletedAt: null, + name: { contains: fromDriverName, mode: 'insensitive' }, + }, + }); + + if (fromDrivers.length === 0) { + return { success: false, error: `No driver found matching "${fromDriverName}".` }; + } + + if (fromDrivers.length > 1) { + return { + success: false, + error: `Multiple drivers match "${fromDriverName}": ${fromDrivers.map((d) => d.name).join(', ')}. Please be more specific.`, + }; + } + + const fromDriver = fromDrivers[0]; + + // Find the "to" driver + const toDrivers = await this.prisma.driver.findMany({ + where: { + deletedAt: null, + name: { contains: toDriverName, mode: 'insensitive' }, + }, + }); + + if (toDrivers.length === 0) { + return { success: false, error: `No driver found matching "${toDriverName}".` }; + } + + if (toDrivers.length > 1) { + return { + success: false, + error: `Multiple drivers match "${toDriverName}": ${toDrivers.map((d) => d.name).join(', ')}. Please be more specific.`, + }; + } + + const toDriver = toDrivers[0]; + + // Build where clause for events to reassign + const where: any = { + deletedAt: null, + driverId: fromDriver.id, + }; + + if (date) { + const dateStart = startOfDay(new Date(date)); + const dateEnd = new Date(dateStart); + dateEnd.setDate(dateEnd.getDate() + 1); + where.startTime = { gte: dateStart, lt: dateEnd }; + } else { + // Only future events if no date specified + where.startTime = { gte: new Date() }; + } + + if (onlyStatus) { + where.status = onlyStatus; + } + + // Find events to reassign + const eventsToReassign = await this.prisma.scheduleEvent.findMany({ + where, + include: {}, + orderBy: { startTime: 'asc' }, + }); + + if (eventsToReassign.length === 0) { + return { + success: true, + data: { reassignedCount: 0, events: [] }, + message: `No events found for ${fromDriver.name} matching the criteria.`, + }; + } + + // Check for conflicts with the new driver + const conflicts = []; + for (const event of eventsToReassign) { + const existingEvents = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + driverId: toDriver.id, + status: { not: 'CANCELLED' }, + OR: [ + { + startTime: { lte: event.startTime }, + endTime: { gt: event.startTime }, + }, + { + startTime: { lt: event.endTime }, + endTime: { gte: event.endTime }, + }, + ], + }, + include: {}, + }); + + if (existingEvents.length > 0) { + conflicts.push({ + eventId: event.id, + eventTitle: event.title, + startTime: event.startTime, + conflictsWith: existingEvents[0].title, + }); + } + } + + if (conflicts.length > 0) { + return { + success: false, + error: `Cannot reassign: ${conflicts.length} event(s) would create conflicts for ${toDriver.name}.`, + data: { conflicts }, + }; + } + + // Perform reassignment + const updateResult = await this.prisma.scheduleEvent.updateMany({ + where, + data: { driverId: toDriver.id }, + }); + + this.logger.log( + `Reassigned ${updateResult.count} events from ${fromDriver.name} to ${toDriver.name}`, + ); + + // Fetch updated events for response + const updatedEvents = await this.prisma.scheduleEvent.findMany({ + where: { + id: { in: eventsToReassign.map((e) => e.id) }, + }, + include: {}, + }); + + return { + success: true, + data: { + reassignedCount: updateResult.count, + fromDriver: fromDriver.name, + toDriver: toDriver.name, + events: updatedEvents, + }, + message: `Successfully reassigned ${updateResult.count} event(s) from ${fromDriver.name} to ${toDriver.name}.`, + }; + } + + async getDailyDriverManifest(input: Record): Promise { + const { driverName, driverId, date } = input; + + let driver; + + if (driverId) { + driver = await this.prisma.driver.findFirst({ + where: { id: driverId, deletedAt: null }, + }); + } else if (driverName) { + const drivers = await this.prisma.driver.findMany({ + where: { + deletedAt: null, + name: { contains: driverName, mode: 'insensitive' }, + }, + }); + + if (drivers.length === 0) { + return { success: false, error: `No driver found matching "${driverName}".` }; + } + + if (drivers.length > 1) { + return { + success: false, + error: `Multiple drivers match "${driverName}": ${drivers.map((d) => d.name).join(', ')}. Please be more specific.`, + }; + } + + driver = drivers[0]; + } else { + return { success: false, error: 'Either driverName or driverId is required.' }; + } + + if (!driver) { + return { success: false, error: `Driver not found.` }; + } + + // Parse date + const targetDate = date ? new Date(date) : new Date(); + const dateStart = startOfDay(targetDate); + const dateEnd = new Date(dateStart); + dateEnd.setDate(dateEnd.getDate() + 1); + + // Get events for this day + const events = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + driverId: driver.id, + startTime: { gte: dateStart, lt: dateEnd }, + status: { not: 'CANCELLED' }, + }, + include: { + vehicle: true, + }, + orderBy: { startTime: 'asc' }, + }); + + // Fetch VIP names for all events + const allVipIds = events.flatMap((e) => e.vipIds); + const uniqueVipIds = [...new Set(allVipIds)]; + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: uniqueVipIds } }, + select: { id: true, name: true }, + }); + const vipMap = new Map(vips.map((v) => [v.id, v.name])); + + // Calculate gaps between events + const manifest = events.map((event, index) => { + const nextEvent = events[index + 1]; + const gapMinutes = nextEvent + ? Math.round((nextEvent.startTime.getTime() - event.endTime.getTime()) / 60000) + : null; + + return { + eventId: event.id, + startTime: event.startTime, + endTime: event.endTime, + title: event.title, + type: event.type, + status: event.status, + vipNames: event.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + pickupLocation: event.pickupLocation, + dropoffLocation: event.dropoffLocation, + location: event.location, + vehicleName: event.vehicle?.name || null, + vehicleType: event.vehicle?.type || null, + description: event.description, + gapUntilNext: gapMinutes, + }; + }); + + const totalHours = + events.reduce((sum, e) => { + return sum + (e.endTime.getTime() - e.startTime.getTime()); + }, 0) / 3600000; + + return { + success: true, + data: { + driver: { + id: driver.id, + name: driver.name, + phone: driver.phone, + }, + date: toDateString(dateStart), + eventCount: events.length, + totalHours: Math.round(totalHours * 10) / 10, + events: manifest, + }, + message: `Driver ${driver.name} has ${events.length} event(s) on ${toDateString(dateStart)} (${Math.round(totalHours * 10) / 10} hours).`, + }; + } + + async findUnassignedEvents(input: Record): Promise { + const { startDate, endDate, missingDriver = true, missingVehicle = true } = input; + + const dateStart = startOfDay(new Date(startDate)); + const dateEnd = new Date(endDate); + dateEnd.setHours(23, 59, 59, 999); + + const where: any = { + deletedAt: null, + startTime: { gte: dateStart, lte: dateEnd }, + status: { in: ['SCHEDULED', 'IN_PROGRESS'] }, + }; + + const orConditions = []; + if (missingDriver) { + orConditions.push({ driverId: null }); + } + if (missingVehicle) { + orConditions.push({ vehicleId: null }); + } + + if (orConditions.length > 0) { + where.OR = orConditions; + } + + const events = await this.prisma.scheduleEvent.findMany({ + where, + include: { + driver: true, + vehicle: true, + }, + orderBy: { startTime: 'asc' }, + }); + + // Fetch VIP names for all events + const allVipIds = events.flatMap((e) => e.vipIds); + const uniqueVipIds = [...new Set(allVipIds)]; + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: uniqueVipIds } }, + select: { id: true, name: true }, + }); + const vipMap = new Map(vips.map((v) => [v.id, v.name])); + + const categorized = { + missingBoth: events.filter((e) => !e.driverId && !e.vehicleId), + missingDriverOnly: events.filter((e) => !e.driverId && e.vehicleId), + missingVehicleOnly: events.filter((e) => e.driverId && !e.vehicleId), + }; + + return { + success: true, + data: { + total: events.length, + breakdown: { + missingBoth: categorized.missingBoth.length, + missingDriverOnly: categorized.missingDriverOnly.length, + missingVehicleOnly: categorized.missingVehicleOnly.length, + }, + events: events.map((e) => ({ + eventId: e.id, + title: e.title, + vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + startTime: e.startTime, + endTime: e.endTime, + hasDriver: !!e.driverId, + hasVehicle: !!e.vehicleId, + driverName: e.driver?.name || null, + vehicleName: e.vehicle?.name || null, + })), + }, + message: `Found ${events.length} unassigned event(s) between ${toDateString(dateStart)} and ${toDateString(dateEnd)}.`, + }; + } + + async checkVipConflicts(input: Record): Promise { + const { vipName, vipId, startTime, endTime, excludeEventId } = input; + + let targetVipId = vipId; + + if (!targetVipId && vipName) { + const vips = await this.prisma.vIP.findMany({ + where: { + deletedAt: null, + name: { contains: vipName, mode: 'insensitive' }, + }, + }); + + if (vips.length === 0) { + return { success: false, error: `No VIP found matching "${vipName}".` }; + } + + if (vips.length > 1) { + return { + success: false, + error: `Multiple VIPs match "${vipName}": ${vips.map((v) => v.name).join(', ')}. Please be more specific.`, + }; + } + + targetVipId = vips[0].id; + } + + if (!targetVipId) { + return { success: false, error: 'Either vipName or vipId is required.' }; + } + + const where: any = { + deletedAt: null, + vipId: targetVipId, + status: { not: 'CANCELLED' }, + OR: [ + { + startTime: { lte: new Date(startTime) }, + endTime: { gt: new Date(startTime) }, + }, + { + startTime: { lt: new Date(endTime) }, + endTime: { gte: new Date(endTime) }, + }, + ], + }; + + if (excludeEventId) { + where.id = { not: excludeEventId }; + } + + const conflicts = await this.prisma.scheduleEvent.findMany({ + where, + include: { + driver: true, + }, + orderBy: { startTime: 'asc' }, + }); + + return { + success: true, + data: { + hasConflicts: conflicts.length > 0, + conflictCount: conflicts.length, + conflicts: conflicts.map((c) => ({ + eventId: c.id, + title: c.title, + startTime: c.startTime, + endTime: c.endTime, + driverName: c.driver?.name || null, + })), + }, + message: + conflicts.length > 0 + ? `VIP has ${conflicts.length} conflict(s) during this time.` + : 'No conflicts found for this VIP during the specified time.', + }; + } + + async getWeeklyLookahead(input: Record): Promise { + const { startDate, weeksAhead = 1 } = input; + + const today = startDate ? startOfDay(new Date(startDate)) : startOfDay(new Date()); + const endDate = new Date(today); + endDate.setDate(endDate.getDate() + weeksAhead * 7); + + // Get all events in range + const events = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + startTime: { gte: today, lt: endDate }, + status: { not: 'CANCELLED' }, + }, + include: { + driver: true, + vehicle: true, + }, + orderBy: { startTime: 'asc' }, + }); + + // Get VIPs with arrivals in range + const arrivingVips = await this.prisma.vIP.findMany({ + where: { + deletedAt: null, + OR: [ + { + expectedArrival: { gte: today, lt: endDate }, + }, + { + flights: { + some: { + scheduledArrival: { gte: today, lt: endDate }, + }, + }, + }, + ], + }, + include: { + flights: { + where: { + scheduledArrival: { gte: today, lt: endDate }, + }, + orderBy: { scheduledArrival: 'asc' }, + }, + }, + }); + + // Group events by week + const weeklyData = []; + for (let week = 0; week < weeksAhead; week++) { + const weekStart = new Date(today); + weekStart.setDate(weekStart.getDate() + week * 7); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 7); + + const weekEvents = events.filter( + (e) => e.startTime >= weekStart && e.startTime < weekEnd, + ); + + const unassigned = weekEvents.filter((e) => !e.driverId || !e.vehicleId); + + weeklyData.push({ + weekNumber: week + 1, + weekStart: toDateString(weekStart), + weekEnd: toDateString(new Date(weekEnd.getTime() - 1)), + totalEvents: weekEvents.length, + unassignedEvents: unassigned.length, + eventsByDay: this.groupEventsByDay(weekEvents), + }); + } + + return { + success: true, + data: { + startDate: toDateString(today), + endDate: toDateString(new Date(endDate.getTime() - 1)), + weeksAhead, + arrivingVips: arrivingVips.map((v) => ({ + id: v.id, + name: v.name, + arrivalMode: v.arrivalMode, + expectedArrival: v.expectedArrival, + flights: v.flights.map((f) => ({ + flightNumber: f.flightNumber, + scheduledArrival: f.scheduledArrival, + })), + })), + weeklyBreakdown: weeklyData, + }, + message: `Lookahead for next ${weeksAhead} week(s) from ${toDateString(today)}.`, + }; + } + + async identifySchedulingGaps(input: Record): Promise { + const { lookaheadDays = 7 } = input; + + const today = startOfDay(new Date()); + const endDate = new Date(today); + endDate.setDate(endDate.getDate() + lookaheadDays); + + // Find unassigned events + const unassignedEvents = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + startTime: { gte: today, lt: endDate }, + status: { in: ['SCHEDULED'] }, + OR: [{ driverId: null }, { vehicleId: null }], + }, + orderBy: { startTime: 'asc' }, + }); + + // Find all scheduled events to check conflicts + const allEvents = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + startTime: { gte: today, lt: endDate }, + status: { not: 'CANCELLED' }, + }, + include: { + driver: true, + }, + }); + + // Fetch all VIPs for name lookups + const allVipIds = [...new Set(allEvents.flatMap((e) => e.vipIds))]; + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: allVipIds }, deletedAt: null }, + select: { id: true, name: true }, + }); + const vipMap = new Map(vips.map((v) => [v.id, v.name])); + + // Check for driver double-booking + const driverConflicts = []; + const driverEventMap = new Map(); + + for (const event of allEvents) { + if (!event.driverId) continue; + + if (!driverEventMap.has(event.driverId)) { + driverEventMap.set(event.driverId, []); + } + driverEventMap.get(event.driverId)!.push(event); + } + + for (const [driverId, driverEvents] of driverEventMap) { + const sorted = driverEvents.sort( + (a, b) => a.startTime.getTime() - b.startTime.getTime(), + ); + + for (let i = 0; i < sorted.length - 1; i++) { + const current = sorted[i]; + const next = sorted[i + 1]; + + if (current.endTime > next.startTime) { + driverConflicts.push({ + driverId, + driverName: current.driver?.name || 'Unknown', + event1: { + id: current.id, + title: current.title, + vipNames: current.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + }, + event2: { + id: next.id, + title: next.title, + vipNames: next.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + }, + overlapMinutes: Math.round( + (current.endTime.getTime() - next.startTime.getTime()) / 60000, + ), + }); + } + } + } + + // Check for VIP double-booking + const vipConflicts = []; + const vipEventMap = new Map>(); + + for (const event of allEvents) { + for (const vipId of event.vipIds) { + if (!vipEventMap.has(vipId)) { + vipEventMap.set(vipId, []); + } + vipEventMap.get(vipId)!.push(event); + } + } + + for (const [vipId, vipEvents] of vipEventMap) { + const sorted = vipEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + for (let i = 0; i < sorted.length - 1; i++) { + const current = sorted[i]; + const next = sorted[i + 1]; + + if (current.endTime > next.startTime) { + vipConflicts.push({ + vipId, + vipName: vipMap.get(vipId) || 'Unknown', + event1: { id: current.id, title: current.title }, + event2: { id: next.id, title: next.title }, + overlapMinutes: Math.round( + (current.endTime.getTime() - next.startTime.getTime()) / 60000, + ), + }); + } + } + } + + const issues = { + unassignedEvents: unassignedEvents.length, + driverConflicts: driverConflicts.length, + vipConflicts: vipConflicts.length, + }; + + const totalIssues = issues.unassignedEvents + issues.driverConflicts + issues.vipConflicts; + + return { + success: true, + data: { + lookaheadDays, + summary: issues, + details: { + unassignedEvents: unassignedEvents.map((e) => ({ + eventId: e.id, + title: e.title, + vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'), + startTime: e.startTime, + missingDriver: !e.driverId, + missingVehicle: !e.vehicleId, + })), + driverConflicts, + vipConflicts, + }, + }, + message: + totalIssues > 0 + ? `Found ${totalIssues} scheduling issue(s) in the next ${lookaheadDays} days.` + : `No scheduling issues found in the next ${lookaheadDays} days.`, + }; + } + + async sendDriverNotificationViaSignal(input: Record): Promise { + const { driverName, driverId, message, relatedEventId } = input; + + let driver; + + if (driverId) { + driver = await this.prisma.driver.findFirst({ + where: { id: driverId, deletedAt: null }, + include: { user: true }, + }); + } else if (driverName) { + const drivers = await this.prisma.driver.findMany({ + where: { + deletedAt: null, + name: { contains: driverName, mode: 'insensitive' }, + }, + include: { user: true }, + }); + + if (drivers.length === 0) { + return { success: false, error: `No driver found matching "${driverName}".` }; + } + + if (drivers.length > 1) { + return { + success: false, + error: `Multiple drivers match "${driverName}": ${drivers.map((d) => d.name).join(', ')}. Please be more specific.`, + }; + } + + driver = drivers[0]; + } else { + return { success: false, error: 'Either driverName or driverId is required.' }; + } + + if (!driver) { + return { success: false, error: 'Driver not found.' }; + } + + if (!driver.phone) { + return { + success: false, + error: `Driver ${driver.name} does not have a phone number on file.`, + }; + } + + try { + await this.messagesService.sendMessage({ + driverId: driver.id, + content: message, + }); + + this.logger.log(`Sent Signal message to driver ${driver.name} (${driver.phone})`); + + return { + success: true, + data: { + driverId: driver.id, + driverName: driver.name, + phone: driver.phone, + message, + relatedEventId, + }, + message: `Message sent successfully to ${driver.name} via Signal.`, + }; + } catch (error) { + this.logger.error(`Failed to send Signal message to ${driver.name}:`, error); + return { + success: false, + error: `Failed to send message: ${error.message}`, + }; + } + } + + async bulkSendDriverSchedules(input: Record): Promise { + const { date, driverNames } = input; + + const targetDate = new Date(date); + const dateStart = startOfDay(targetDate); + const dateEnd = new Date(dateStart); + dateEnd.setDate(dateEnd.getDate() + 1); + + // Determine which drivers to send to + let driversToNotify; + + if (driverNames && driverNames.length > 0) { + // Send to specific drivers + driversToNotify = await this.prisma.driver.findMany({ + where: { + deletedAt: null, + name: { in: driverNames }, + }, + }); + + if (driversToNotify.length === 0) { + return { success: false, error: 'No drivers found matching the provided names.' }; + } + } else { + // Send to all drivers with events on this date + const eventsOnDate = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + startTime: { gte: dateStart, lt: dateEnd }, + driverId: { not: null }, + status: { not: 'CANCELLED' }, + }, + select: { driverId: true }, + distinct: ['driverId'], + }); + + const driverIds = eventsOnDate + .map((e) => e.driverId) + .filter((id): id is string => id !== null); + + driversToNotify = await this.prisma.driver.findMany({ + where: { + deletedAt: null, + id: { in: driverIds }, + }, + }); + } + + if (driversToNotify.length === 0) { + return { + success: true, + data: { sentCount: 0 }, + message: `No drivers have events on ${toDateString(dateStart)}.`, + }; + } + + const results = []; + + for (const driver of driversToNotify) { + try { + if (!driver.phone) { + results.push({ + driverId: driver.id, + driverName: driver.name, + success: false, + error: 'No phone number on file', + }); + continue; + } + + // Send schedule message + const messageText = `Your schedule for ${toDateString(dateStart)}`; + + await this.messagesService.sendMessage({ + driverId: driver.id, + content: messageText, + }); + + results.push({ + driverId: driver.id, + driverName: driver.name, + success: true, + }); + + this.logger.log(`Sent schedule to ${driver.name}`); + } catch (error) { + this.logger.error(`Failed to send schedule to ${driver.name}:`, error); + results.push({ + driverId: driver.id, + driverName: driver.name, + success: false, + error: error.message, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + + return { + success: true, + data: { + date: toDateString(dateStart), + sentCount: successCount, + totalDrivers: driversToNotify.length, + results, + }, + message: `Sent schedules to ${successCount} of ${driversToNotify.length} driver(s).`, + }; + } + + private groupEventsByDay(events: any[]): Record { + const byDay: Record = {}; + + for (const event of events) { + const day = toDateString(event.startTime); + byDay[day] = (byDay[day] || 0) + 1; + } + + return byDay; + } +} diff --git a/backend/src/copilot/copilot-vip.service.ts b/backend/src/copilot/copilot-vip.service.ts new file mode 100644 index 0000000..9e13c24 --- /dev/null +++ b/backend/src/copilot/copilot-vip.service.ts @@ -0,0 +1,275 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +interface ToolResult { + success: boolean; + data?: any; + error?: string; + message?: string; +} + +@Injectable() +export class CopilotVipService { + private readonly logger = new Logger(CopilotVipService.name); + + constructor(private readonly prisma: PrismaService) {} + + async searchVips(filters: Record): Promise { + const where: any = { deletedAt: null }; + + if (filters.name) { + where.name = { contains: filters.name, mode: 'insensitive' }; + } + if (filters.organization) { + where.organization = { contains: filters.organization, mode: 'insensitive' }; + } + if (filters.department) { + where.department = filters.department; + } + if (filters.arrivalMode) { + where.arrivalMode = filters.arrivalMode; + } + + const vips = await this.prisma.vIP.findMany({ + where, + include: { + flights: true, + }, + take: 20, + }); + + // Fetch events for these VIPs + const vipIds = vips.map((v) => v.id); + const events = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + vipIds: { hasSome: vipIds }, + }, + orderBy: { startTime: 'asc' }, + }); + + // Attach events to VIPs + const vipsWithEvents = vips.map((vip) => ({ + ...vip, + events: events.filter((e) => e.vipIds.includes(vip.id)).slice(0, 5), + })); + + return { success: true, data: vipsWithEvents }; + } + + async getVipDetails(vipId: string): Promise { + const vip = await this.prisma.vIP.findUnique({ + where: { id: vipId }, + include: { + flights: true, + }, + }); + + if (!vip) { + return { success: false, error: 'VIP not found' }; + } + + // Fetch events for this VIP + const events = await this.prisma.scheduleEvent.findMany({ + where: { + deletedAt: null, + vipIds: { has: vipId }, + }, + include: { + driver: true, + vehicle: true, + }, + orderBy: { startTime: 'asc' }, + }); + + return { success: true, data: { ...vip, events } }; + } + + async createVip(input: Record): Promise { + const vip = await this.prisma.vIP.create({ + data: { + name: input.name, + organization: input.organization, + department: input.department, + arrivalMode: input.arrivalMode, + expectedArrival: input.expectedArrival ? new Date(input.expectedArrival) : null, + airportPickup: input.airportPickup ?? false, + venueTransport: input.venueTransport ?? false, + partySize: input.partySize ?? 1, + notes: input.notes, + isRosterOnly: input.isRosterOnly ?? false, + phone: input.phone || null, + email: input.email || null, + emergencyContactName: input.emergencyContactName || null, + emergencyContactPhone: input.emergencyContactPhone || null, + }, + }); + + return { success: true, data: vip }; + } + + async updateVip(input: Record): Promise { + const { vipId, ...updateData } = input; + + const data: any = {}; + if (updateData.name !== undefined) data.name = updateData.name; + if (updateData.organization !== undefined) data.organization = updateData.organization; + if (updateData.department !== undefined) data.department = updateData.department; + if (updateData.arrivalMode !== undefined) data.arrivalMode = updateData.arrivalMode; + if (updateData.expectedArrival !== undefined) + data.expectedArrival = updateData.expectedArrival + ? new Date(updateData.expectedArrival) + : null; + if (updateData.airportPickup !== undefined) data.airportPickup = updateData.airportPickup; + if (updateData.venueTransport !== undefined) + data.venueTransport = updateData.venueTransport; + if (updateData.partySize !== undefined) data.partySize = updateData.partySize; + if (updateData.notes !== undefined) data.notes = updateData.notes; + if (updateData.isRosterOnly !== undefined) data.isRosterOnly = updateData.isRosterOnly; + if (updateData.phone !== undefined) data.phone = updateData.phone || null; + if (updateData.email !== undefined) data.email = updateData.email || null; + if (updateData.emergencyContactName !== undefined) + data.emergencyContactName = updateData.emergencyContactName || null; + if (updateData.emergencyContactPhone !== undefined) + data.emergencyContactPhone = updateData.emergencyContactPhone || null; + + const vip = await this.prisma.vIP.update({ + where: { id: vipId }, + data, + include: { flights: true }, + }); + + return { success: true, data: vip }; + } + + async getVipItinerary(input: Record): Promise { + const { vipId, startDate, endDate } = input; + + const vip = await this.prisma.vIP.findUnique({ + where: { id: vipId }, + }); + + if (!vip) { + return { success: false, error: 'VIP not found' }; + } + + // Build date filters + const dateFilter: any = {}; + if (startDate) dateFilter.gte = new Date(startDate); + if (endDate) dateFilter.lte = new Date(endDate); + + // Get flights + const flightsWhere: any = { vipId }; + if (startDate || endDate) { + flightsWhere.flightDate = dateFilter; + } + const flights = await this.prisma.flight.findMany({ + where: flightsWhere, + orderBy: { scheduledDeparture: 'asc' }, + }); + + // Get events + const eventsWhere: any = { + deletedAt: null, + vipIds: { has: vipId }, + }; + if (startDate || endDate) { + eventsWhere.startTime = dateFilter; + } + const events = await this.prisma.scheduleEvent.findMany({ + where: eventsWhere, + include: { + driver: true, + vehicle: true, + }, + orderBy: { startTime: 'asc' }, + }); + + // Combine and sort chronologically + const itineraryItems: any[] = [ + ...flights.map((f) => ({ + type: 'FLIGHT', + time: f.scheduledDeparture || f.flightDate, + data: f, + })), + ...events.map((e) => ({ + type: 'EVENT', + time: e.startTime, + data: e, + })), + ].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + + return { + success: true, + data: { + vip, + itinerary: itineraryItems, + summary: { + totalFlights: flights.length, + totalEvents: events.length, + }, + }, + }; + } + + async getFlightsForVip(vipId: string): Promise { + const flights = await this.prisma.flight.findMany({ + where: { vipId }, + orderBy: { flightDate: 'asc' }, + }); + + return { success: true, data: flights }; + } + + async createFlight(input: Record): Promise { + const flight = await this.prisma.flight.create({ + data: { + vipId: input.vipId, + flightNumber: input.flightNumber, + flightDate: new Date(input.flightDate), + departureAirport: input.departureAirport, + arrivalAirport: input.arrivalAirport, + scheduledDeparture: input.scheduledDeparture + ? new Date(input.scheduledDeparture) + : null, + scheduledArrival: input.scheduledArrival ? new Date(input.scheduledArrival) : null, + segment: input.segment || 1, + }, + include: { vip: true }, + }); + + return { success: true, data: flight }; + } + + async updateFlight(input: Record): Promise { + const { flightId, ...updateData } = input; + + const flight = await this.prisma.flight.update({ + where: { id: flightId }, + data: updateData, + include: { vip: true }, + }); + + return { success: true, data: flight }; + } + + async deleteFlight(flightId: string): Promise { + const flight = await this.prisma.flight.findUnique({ + where: { id: flightId }, + include: { vip: true }, + }); + + if (!flight) { + return { success: false, error: 'Flight not found' }; + } + + await this.prisma.flight.delete({ + where: { id: flightId }, + }); + + return { + success: true, + data: { deleted: true, flight }, + }; + } +} diff --git a/backend/src/copilot/copilot.module.ts b/backend/src/copilot/copilot.module.ts index 67fec8c..2019cc7 100644 --- a/backend/src/copilot/copilot.module.ts +++ b/backend/src/copilot/copilot.module.ts @@ -1,6 +1,10 @@ import { Module } from '@nestjs/common'; import { CopilotController } from './copilot.controller'; import { CopilotService } from './copilot.service'; +import { CopilotVipService } from './copilot-vip.service'; +import { CopilotScheduleService } from './copilot-schedule.service'; +import { CopilotFleetService } from './copilot-fleet.service'; +import { CopilotReportsService } from './copilot-reports.service'; import { PrismaModule } from '../prisma/prisma.module'; import { SignalModule } from '../signal/signal.module'; import { DriversModule } from '../drivers/drivers.module'; @@ -8,6 +12,12 @@ import { DriversModule } from '../drivers/drivers.module'; @Module({ imports: [PrismaModule, SignalModule, DriversModule], controllers: [CopilotController], - providers: [CopilotService], + providers: [ + CopilotService, + CopilotVipService, + CopilotScheduleService, + CopilotFleetService, + CopilotReportsService, + ], }) export class CopilotModule {} diff --git a/backend/src/copilot/copilot.service.ts b/backend/src/copilot/copilot.service.ts index 350e2aa..d1b414a 100644 --- a/backend/src/copilot/copilot.service.ts +++ b/backend/src/copilot/copilot.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; 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'; +import { CopilotVipService } from './copilot-vip.service'; +import { CopilotScheduleService } from './copilot-schedule.service'; +import { CopilotFleetService } from './copilot-fleet.service'; +import { CopilotReportsService } from './copilot-reports.service'; interface ChatMessage { role: 'user' | 'assistant'; @@ -26,21 +26,31 @@ export class CopilotService { private readonly tools: Anthropic.Tool[] = [ { name: 'search_vips', - description: 'Search for VIPs by name, organization, department, or arrival mode. Returns a list of matching VIPs with their details.', + description: + 'Search for VIPs by name, organization, department, or arrival mode. Returns a list of matching VIPs with their details.', input_schema: { type: 'object' as const, properties: { name: { type: 'string', description: 'VIP name to search for (partial match)' }, organization: { type: 'string', description: 'Organization name to filter by' }, - department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'Department to filter by' }, - arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'Arrival mode to filter by' }, + department: { + type: 'string', + enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], + description: 'Department to filter by', + }, + arrivalMode: { + type: 'string', + enum: ['FLIGHT', 'SELF_DRIVING'], + description: 'Arrival mode to filter by', + }, }, required: [], }, }, { name: 'get_vip_details', - description: 'Get detailed information about a specific VIP including their flights and scheduled events.', + description: + 'Get detailed information about a specific VIP including their flights and scheduled events.', input_schema: { type: 'object' as const, properties: { @@ -51,12 +61,17 @@ export class CopilotService { }, { name: 'search_drivers', - description: 'Search for drivers by name, phone, or department. Returns a list of drivers with their availability.', + description: + 'Search for drivers by name, phone, or department. Returns a list of drivers with their availability.', input_schema: { type: 'object' as const, properties: { name: { type: 'string', description: 'Driver name to search for' }, - department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'Department to filter by' }, + department: { + type: 'string', + enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], + description: 'Department to filter by', + }, availableOnly: { type: 'boolean', description: 'Only return available drivers' }, }, required: [], @@ -64,7 +79,7 @@ export class CopilotService { }, { name: 'get_driver_schedule', - description: 'Get a driver\'s schedule for a specific date range.', + description: "Get a driver's schedule for a specific date range.", input_schema: { type: 'object' as const, properties: { @@ -77,7 +92,8 @@ export class CopilotService { }, { name: 'search_events', - description: 'Search for scheduled events/activities. Can filter by VIP name, event title, driver name, date, or status.', + description: + 'Search for scheduled events/activities. Can filter by VIP name, event title, driver name, date, or status.', input_schema: { type: 'object' as const, properties: { @@ -85,10 +101,22 @@ export class CopilotService { vipName: { type: 'string', description: 'Filter by VIP name (partial match)' }, title: { type: 'string', description: 'Filter by event title (partial match)' }, driverId: { type: 'string', description: 'Filter by driver ID' }, - driverName: { type: 'string', description: 'Filter by driver name (partial match) - will find events assigned to drivers matching this name' }, + driverName: { + type: 'string', + description: + 'Filter by driver name (partial match) - will find events assigned to drivers matching this name', + }, date: { type: 'string', description: 'Filter by date (ISO format or YYYY-MM-DD)' }, - status: { type: 'string', enum: ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'], description: 'Filter by status' }, - type: { type: 'string', enum: ['TRANSPORT', 'MEETING', 'EVENT', 'MEAL', 'ACCOMMODATION'], description: 'Filter by event type' }, + status: { + type: 'string', + enum: ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'], + description: 'Filter by status', + }, + type: { + type: 'string', + enum: ['TRANSPORT', 'MEETING', 'EVENT', 'MEAL', 'ACCOMMODATION'], + description: 'Filter by event type', + }, }, required: [], }, @@ -99,7 +127,11 @@ export class CopilotService { input_schema: { type: 'object' as const, properties: { - type: { type: 'string', enum: ['VAN', 'SUV', 'SEDAN', 'BUS', 'GOLF_CART', 'TRUCK'], description: 'Vehicle type' }, + type: { + type: 'string', + enum: ['VAN', 'SUV', 'SEDAN', 'BUS', 'GOLF_CART', 'TRUCK'], + description: 'Vehicle type', + }, minSeats: { type: 'number', description: 'Minimum seat capacity required' }, }, required: [], @@ -123,7 +155,10 @@ export class CopilotService { type: 'object' as const, properties: { flightId: { type: 'string', description: 'The flight ID to update' }, - scheduledDeparture: { type: 'string', description: 'New scheduled departure time (ISO format)' }, + scheduledDeparture: { + type: 'string', + description: 'New scheduled departure time (ISO format)', + }, scheduledArrival: { type: 'string', description: 'New scheduled arrival time (ISO format)' }, status: { type: 'string', description: 'New flight status' }, }, @@ -132,13 +167,18 @@ export class CopilotService { }, { name: 'create_event', - description: 'Create a new scheduled event/activity for a VIP. Only use this for NEW events, not to modify existing ones.', + description: + 'Create a new scheduled event/activity for a VIP. Only use this for NEW events, not to modify existing ones.', input_schema: { type: 'object' as const, properties: { vipId: { type: 'string', description: 'The VIP ID' }, title: { type: 'string', description: 'Event title' }, - type: { type: 'string', enum: ['TRANSPORT', 'MEETING', 'EVENT', 'MEAL', 'ACCOMMODATION'], description: 'Event type' }, + type: { + type: 'string', + enum: ['TRANSPORT', 'MEETING', 'EVENT', 'MEAL', 'ACCOMMODATION'], + description: 'Event type', + }, startTime: { type: 'string', description: 'Start time (ISO format)' }, endTime: { type: 'string', description: 'End time (ISO format)' }, location: { type: 'string', description: 'Event location' }, @@ -165,7 +205,8 @@ export class CopilotService { }, { name: 'update_event', - description: 'Update an existing event. Use this to change event time, location, title, status, or other details.', + description: + 'Update an existing event. Use this to change event time, location, title, status, or other details.', input_schema: { type: 'object' as const, properties: { @@ -176,7 +217,11 @@ export class CopilotService { location: { type: 'string', description: 'New location' }, pickupLocation: { type: 'string', description: 'New pickup location' }, dropoffLocation: { type: 'string', description: 'New dropoff location' }, - status: { type: 'string', enum: ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'], description: 'New status' }, + status: { + type: 'string', + enum: ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'], + description: 'New status', + }, driverId: { type: 'string', description: 'New driver ID (use null to unassign)' }, vehicleId: { type: 'string', description: 'New vehicle ID (use null to unassign)' }, description: { type: 'string', description: 'New description/notes' }, @@ -186,7 +231,8 @@ export class CopilotService { }, { name: 'delete_event', - description: 'Delete (soft delete) an event. Use this when an event is cancelled or no longer needed.', + description: + 'Delete (soft delete) an event. Use this when an event is cancelled or no longer needed.', input_schema: { type: 'object' as const, properties: { @@ -197,7 +243,8 @@ export class CopilotService { }, { name: 'get_todays_summary', - description: 'Get a summary of today\'s activities including upcoming events, arriving VIPs, and driver assignments.', + description: + "Get a summary of today's activities including upcoming events, arriving VIPs, and driver assignments.", input_schema: { type: 'object' as const, properties: {}, @@ -213,11 +260,20 @@ export class CopilotService { vipId: { type: 'string', description: 'The VIP ID' }, flightNumber: { type: 'string', description: 'Flight number (e.g., AA1234)' }, flightDate: { type: 'string', description: 'Flight date (ISO format or YYYY-MM-DD)' }, - departureAirport: { type: 'string', description: 'Departure airport IATA code (e.g., JFK)' }, + departureAirport: { + type: 'string', + description: 'Departure airport IATA code (e.g., JFK)', + }, arrivalAirport: { type: 'string', description: 'Arrival airport IATA code (e.g., LAX)' }, - scheduledDeparture: { type: 'string', description: 'Scheduled departure time (ISO format)' }, + scheduledDeparture: { + type: 'string', + description: 'Scheduled departure time (ISO format)', + }, scheduledArrival: { type: 'string', description: 'Scheduled arrival time (ISO format)' }, - segment: { type: 'number', description: 'Flight segment number for multi-leg trips (default 1)' }, + segment: { + type: 'number', + description: 'Flight segment number for multi-leg trips (default 1)', + }, }, required: ['vipId', 'flightNumber', 'flightDate', 'departureAirport', 'arrivalAirport'], }, @@ -241,14 +297,32 @@ export class CopilotService { properties: { name: { type: 'string', description: 'VIP full name' }, organization: { type: 'string', description: 'Organization/company name' }, - department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'Department' }, - arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'How VIP will arrive' }, - expectedArrival: { type: 'string', description: 'Expected arrival time for self-driving (ISO format)' }, + department: { + type: 'string', + enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], + description: 'Department', + }, + arrivalMode: { + type: 'string', + enum: ['FLIGHT', 'SELF_DRIVING'], + description: 'How VIP will arrive', + }, + expectedArrival: { + type: 'string', + description: 'Expected arrival time for self-driving (ISO format)', + }, airportPickup: { type: 'boolean', description: 'Whether VIP needs airport pickup' }, venueTransport: { type: 'boolean', description: 'Whether VIP needs venue transport' }, - partySize: { type: 'number', description: 'Total party size including VIP plus companions/entourage (default 1)' }, + partySize: { + type: 'number', + description: 'Total party size including VIP plus companions/entourage (default 1)', + }, notes: { type: 'string', description: 'Additional notes about the VIP' }, - isRosterOnly: { type: 'boolean', description: 'True if VIP is roster-only (accountability tracking, no active transport coordination)' }, + isRosterOnly: { + type: 'boolean', + description: + 'True if VIP is roster-only (accountability tracking, no active transport coordination)', + }, phone: { type: 'string', description: 'VIP phone number' }, email: { type: 'string', description: 'VIP email address' }, emergencyContactName: { type: 'string', description: 'Emergency contact name' }, @@ -266,14 +340,28 @@ export class CopilotService { vipId: { type: 'string', description: 'The VIP ID to update' }, name: { type: 'string', description: 'New name' }, organization: { type: 'string', description: 'New organization' }, - department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'New department' }, - arrivalMode: { type: 'string', enum: ['FLIGHT', 'SELF_DRIVING'], description: 'New arrival mode' }, + department: { + type: 'string', + enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], + description: 'New department', + }, + arrivalMode: { + type: 'string', + enum: ['FLIGHT', 'SELF_DRIVING'], + description: 'New arrival mode', + }, expectedArrival: { type: 'string', description: 'New expected arrival time' }, airportPickup: { type: 'boolean', description: 'Whether VIP needs airport pickup' }, venueTransport: { type: 'boolean', description: 'Whether VIP needs venue transport' }, - partySize: { type: 'number', description: 'Total party size including VIP plus companions/entourage' }, + partySize: { + type: 'number', + description: 'Total party size including VIP plus companions/entourage', + }, notes: { type: 'string', description: 'New notes' }, - isRosterOnly: { type: 'boolean', description: 'True if VIP is roster-only (no active transport coordination)' }, + isRosterOnly: { + type: 'boolean', + description: 'True if VIP is roster-only (no active transport coordination)', + }, phone: { type: 'string', description: 'VIP phone number' }, email: { type: 'string', description: 'VIP email address' }, emergencyContactName: { type: 'string', description: 'Emergency contact name' }, @@ -303,7 +391,11 @@ export class CopilotService { driverId: { type: 'string', description: 'The driver ID to update' }, name: { type: 'string', description: 'New name' }, phone: { type: 'string', description: 'New phone number' }, - department: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'New department' }, + department: { + type: 'string', + enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], + description: 'New department', + }, isAvailable: { type: 'boolean', description: 'Whether driver is available' }, shiftStartTime: { type: 'string', description: 'Shift start time (HH:MM format)' }, shiftEndTime: { type: 'string', description: 'Shift end time (HH:MM format)' }, @@ -320,39 +412,62 @@ export class CopilotService { driverId: { type: 'string', description: 'The driver ID to check' }, startTime: { type: 'string', description: 'Start time to check (ISO format)' }, endTime: { type: 'string', description: 'End time to check (ISO format)' }, - excludeEventId: { type: 'string', description: 'Event ID to exclude from conflict check (for updates)' }, + excludeEventId: { + type: 'string', + description: 'Event ID to exclude from conflict check (for updates)', + }, }, required: ['driverId', 'startTime', 'endTime'], }, }, { name: 'reassign_driver_events', - description: 'Reassign all events from one driver to another. Use this when a driver is sick, unavailable, or needs to swap schedules. Searches by driver NAME - you do not need IDs.', + description: + 'Reassign all events from one driver to another. Use this when a driver is sick, unavailable, or needs to swap schedules. Searches by driver NAME - you do not need IDs.', input_schema: { type: 'object' as const, properties: { - fromDriverName: { type: 'string', description: 'Name of the driver to reassign FROM (the one who is sick/unavailable)' }, - toDriverName: { type: 'string', description: 'Name of the driver to reassign TO (the replacement driver)' }, - date: { type: 'string', description: 'Optional: only reassign events on this date (YYYY-MM-DD). If not provided, reassigns all future events.' }, - onlyStatus: { type: 'string', enum: ['SCHEDULED', 'IN_PROGRESS'], description: 'Optional: only reassign events with this status' }, + fromDriverName: { + type: 'string', + description: 'Name of the driver to reassign FROM (the one who is sick/unavailable)', + }, + toDriverName: { + type: 'string', + description: 'Name of the driver to reassign TO (the replacement driver)', + }, + date: { + type: 'string', + description: + 'Optional: only reassign events on this date (YYYY-MM-DD). If not provided, reassigns all future events.', + }, + onlyStatus: { + type: 'string', + enum: ['SCHEDULED', 'IN_PROGRESS'], + description: 'Optional: only reassign events with this status', + }, }, required: ['fromDriverName', 'toDriverName'], }, }, { name: 'list_all_drivers', - description: 'List ALL drivers in the system with their basic info. Use this when you need to see available driver names or find the correct spelling of a driver name.', + description: + 'List ALL drivers in the system with their basic info. Use this when you need to see available driver names or find the correct spelling of a driver name.', input_schema: { type: 'object' as const, properties: { - includeUnavailable: { type: 'boolean', description: 'Include unavailable drivers (default true)' }, + includeUnavailable: { + type: 'boolean', + description: 'Include unavailable drivers (default true)', + }, }, required: [], }, }, { name: 'get_vip_itinerary', - description: 'Get the complete itinerary for a VIP including all flights and events in chronological order.', + description: + 'Get the complete itinerary for a VIP including all flights and events in chronological order.', input_schema: { type: 'object' as const, properties: { @@ -365,73 +480,103 @@ export class CopilotService { }, { name: 'find_available_drivers_for_timerange', - description: 'Find drivers who have no conflicting events during a specific time range. Returns a list of available drivers with their info.', + description: + 'Find drivers who have no conflicting events during a specific time range. Returns a list of available drivers with their info.', input_schema: { type: 'object' as const, properties: { startTime: { type: 'string', description: 'Start time of the time range (ISO format)' }, endTime: { type: 'string', description: 'End time of the time range (ISO format)' }, - preferredDepartment: { type: 'string', enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], description: 'Optional: filter by department' }, + preferredDepartment: { + type: 'string', + enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'], + description: 'Optional: filter by department', + }, }, required: ['startTime', 'endTime'], }, }, { name: 'get_daily_driver_manifest', - description: 'Get a driver\'s complete schedule for a specific day with all event details including VIP names, locations, vehicles, and gaps between events.', + description: + "Get a driver's complete schedule for a specific day with all event details including VIP names, locations, vehicles, and gaps between events.", input_schema: { type: 'object' as const, properties: { driverName: { type: 'string', description: 'Driver name (partial match works)' }, driverId: { type: 'string', description: 'Driver ID (use this if you already have the ID)' }, - date: { type: 'string', description: 'Date in YYYY-MM-DD format (optional, defaults to today)' }, + date: { + type: 'string', + description: 'Date in YYYY-MM-DD format (optional, defaults to today)', + }, }, required: [], }, }, { name: 'send_driver_notification_via_signal', - description: 'Send a message to a driver via Signal messaging. Use this to notify drivers about schedule changes, reminders, or important updates.', + description: + 'Send a message to a driver via Signal messaging. Use this to notify drivers about schedule changes, reminders, or important updates.', input_schema: { type: 'object' as const, properties: { driverName: { type: 'string', description: 'Driver name (partial match works)' }, driverId: { type: 'string', description: 'Driver ID (use this if you already have the ID)' }, message: { type: 'string', description: 'The message content to send to the driver' }, - relatedEventId: { type: 'string', description: 'Optional: Event ID if this message relates to a specific event' }, + relatedEventId: { + type: 'string', + description: 'Optional: Event ID if this message relates to a specific event', + }, }, required: ['message'], }, }, { name: 'bulk_send_driver_schedules', - description: 'Send daily schedules to multiple drivers or all drivers via Signal. Automatically generates and sends PDF/ICS schedule files.', + description: + 'Send daily schedules to multiple drivers or all drivers via Signal. Automatically generates and sends PDF/ICS schedule files.', input_schema: { type: 'object' as const, properties: { - date: { type: 'string', description: 'Date in YYYY-MM-DD format for which to send schedules' }, - driverNames: { type: 'array', items: { type: 'string' }, description: 'Optional: array of driver names. If empty or not provided, sends to all drivers with events on that date.' }, + date: { + type: 'string', + description: 'Date in YYYY-MM-DD format for which to send schedules', + }, + driverNames: { + type: 'array', + items: { type: 'string' }, + description: + 'Optional: array of driver names. If empty or not provided, sends to all drivers with events on that date.', + }, }, required: ['date'], }, }, { name: 'find_unassigned_events', - description: 'Find events that are missing driver and/or vehicle assignments. Useful for identifying scheduling gaps that need attention.', + description: + 'Find events that are missing driver and/or vehicle assignments. Useful for identifying scheduling gaps that need attention.', input_schema: { type: 'object' as const, properties: { startDate: { type: 'string', description: 'Start date to search (ISO format or YYYY-MM-DD)' }, endDate: { type: 'string', description: 'End date to search (ISO format or YYYY-MM-DD)' }, - missingDriver: { type: 'boolean', description: 'Find events missing driver assignment (default true)' }, - missingVehicle: { type: 'boolean', description: 'Find events missing vehicle assignment (default true)' }, + missingDriver: { + type: 'boolean', + description: 'Find events missing driver assignment (default true)', + }, + missingVehicle: { + type: 'boolean', + description: 'Find events missing vehicle assignment (default true)', + }, }, required: ['startDate', 'endDate'], }, }, { name: 'check_vip_conflicts', - description: 'Check if a VIP has overlapping events in a time range. Useful for preventing double-booking VIPs.', + description: + 'Check if a VIP has overlapping events in a time range. Useful for preventing double-booking VIPs.', input_schema: { type: 'object' as const, properties: { @@ -439,18 +584,25 @@ export class CopilotService { vipId: { type: 'string', description: 'VIP ID (use this if you already have the ID)' }, startTime: { type: 'string', description: 'Start time to check (ISO format)' }, endTime: { type: 'string', description: 'End time to check (ISO format)' }, - excludeEventId: { type: 'string', description: 'Optional: event ID to exclude from conflict check (for updates)' }, + excludeEventId: { + type: 'string', + description: 'Optional: event ID to exclude from conflict check (for updates)', + }, }, required: ['startTime', 'endTime'], }, }, { name: 'get_weekly_lookahead', - description: 'Get a week-by-week summary of upcoming events, VIP arrivals, and unassigned events for planning purposes.', + description: + 'Get a week-by-week summary of upcoming events, VIP arrivals, and unassigned events for planning purposes.', input_schema: { type: 'object' as const, properties: { - startDate: { type: 'string', description: 'Start date (optional, defaults to today, YYYY-MM-DD format)' }, + startDate: { + type: 'string', + description: 'Start date (optional, defaults to today, YYYY-MM-DD format)', + }, weeksAhead: { type: 'number', description: 'Number of weeks to look ahead (default 1)' }, }, required: [], @@ -458,7 +610,8 @@ export class CopilotService { }, { name: 'identify_scheduling_gaps', - description: 'Audit the upcoming schedule for problems including unassigned events, driver conflicts, VIP conflicts, and capacity issues.', + description: + 'Audit the upcoming schedule for problems including unassigned events, driver conflicts, VIP conflicts, and capacity issues.', input_schema: { type: 'object' as const, properties: { @@ -469,7 +622,8 @@ export class CopilotService { }, { name: 'suggest_vehicle_for_event', - description: 'Recommend vehicles for an event based on capacity requirements and availability during the event time.', + description: + 'Recommend vehicles for an event based on capacity requirements and availability during the event time.', input_schema: { type: 'object' as const, properties: { @@ -480,7 +634,8 @@ export class CopilotService { }, { name: 'get_vehicle_schedule', - description: 'Get a vehicle\'s schedule for a date range, showing all events using this vehicle with driver and VIP details.', + description: + "Get a vehicle's schedule for a date range, showing all events using this vehicle with driver and VIP details.", input_schema: { type: 'object' as const, properties: { @@ -494,7 +649,8 @@ export class CopilotService { }, { name: 'get_driver_workload_summary', - description: 'Get workload statistics for all drivers including event count, total hours, and availability percentage for a date range.', + description: + 'Get workload statistics for all drivers including event count, total hours, and availability percentage for a date range.', input_schema: { type: 'object' as const, properties: { @@ -504,10 +660,9 @@ export class CopilotService { required: ['startDate', 'endDate'], }, }, - // ==================== SYSTEM STATUS ==================== { name: 'get_current_system_status', - description: 'Get system overview: VIP/driver/vehicle counts, today\'s events, alerts.', + description: "Get system overview: VIP/driver/vehicle counts, today's events, alerts.", input_schema: { type: 'object' as const, properties: {}, @@ -517,9 +672,10 @@ export class CopilotService { ]; constructor( - private readonly prisma: PrismaService, - private readonly messagesService: MessagesService, - private readonly scheduleExportService: ScheduleExportService, + private readonly vipService: CopilotVipService, + private readonly scheduleService: CopilotScheduleService, + private readonly fleetService: CopilotFleetService, + private readonly reportsService: CopilotReportsService, ) { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) { @@ -532,233 +688,207 @@ export class CopilotService { messages: ChatMessage[], userId: string, userRole: string, - ): Promise<{ response: string; toolCalls?: any[] }> { - if (!process.env.ANTHROPIC_API_KEY) { - return { response: 'AI Copilot is not configured. Please add ANTHROPIC_API_KEY to your environment.' }; - } - - const systemPrompt = this.buildSystemPrompt(userRole); - + ): Promise<{ response: string; toolResults?: any[] }> { try { - // Convert messages to Anthropic format - const anthropicMessages = messages.map(msg => ({ - role: msg.role as 'user' | 'assistant', + const systemPrompt = this.buildSystemPrompt(userRole); + + const anthropicMessages: Anthropic.MessageParam[] = messages.map((msg) => ({ + role: msg.role, content: msg.content, })); let response = await this.anthropic.messages.create({ - model: 'claude-3-5-haiku-20241022', - max_tokens: 4096, + model: 'claude-3-5-sonnet-20241022', + max_tokens: 8096, system: systemPrompt, - tools: this.tools, messages: anthropicMessages, + tools: this.tools, }); - // Handle tool use loop - const toolCalls: any[] = []; + const toolResults: any[] = []; + + // Tool use loop while (response.stop_reason === 'tool_use') { const toolUseBlocks = response.content.filter( - (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use' - ); + (block) => block.type === 'tool_use', + ) as Anthropic.ToolUseBlock[]; - const toolResults: Anthropic.ToolResultBlockParam[] = []; + const toolResultBlocks: Anthropic.ToolResultBlockParam[] = []; for (const toolUse of toolUseBlocks) { this.logger.log(`Executing tool: ${toolUse.name}`); const result = await this.executeTool(toolUse.name, toolUse.input as Record); + toolResults.push({ tool: toolUse.name, result }); - toolCalls.push({ - tool: toolUse.name, - input: toolUse.input, - result: result, - }); - - toolResults.push({ + toolResultBlocks.push({ type: 'tool_result', tool_use_id: toolUse.id, content: JSON.stringify(result), }); } - // Continue conversation with tool results + // Add assistant's tool use and user's tool results to the conversation + anthropicMessages.push( + { + role: 'assistant', + content: response.content, + }, + { + role: 'user', + content: toolResultBlocks, + }, + ); + + // Continue conversation response = await this.anthropic.messages.create({ - model: 'claude-3-5-haiku-20241022', - max_tokens: 4096, + model: 'claude-3-5-sonnet-20241022', + max_tokens: 8096, system: systemPrompt, + messages: anthropicMessages, tools: this.tools, - messages: [ - ...anthropicMessages, - { role: 'assistant', content: response.content }, - { role: 'user', content: toolResults }, - ], }); } // Extract final text response - const textBlock = response.content.find( - (block): block is Anthropic.TextBlock => block.type === 'text' - ); + const textContent = response.content.find((block) => block.type === 'text') as + | Anthropic.TextBlock + | undefined; return { - response: textBlock?.text || 'I apologize, but I was unable to generate a response.', - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + response: textContent?.text || 'No response generated', + toolResults: toolResults.length > 0 ? toolResults : undefined, }; } catch (error) { - this.logger.error('Claude API error:', error); + this.logger.error('Copilot chat error:', error); throw error; } } private buildSystemPrompt(userRole: string): string { - const today = toDateString(new Date()); + const today = new Date().toISOString().split('T')[0]; - 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. + return `You are a VIP Transportation Coordinator AI assistant. Today's date is ${today}. -Current date: ${today} -User role: ${userRole} +Your role is to help manage VIP transportation logistics including: +- VIP profiles and itineraries +- Driver schedules and assignments +- Vehicle fleet management +- Event scheduling and conflict detection +- Flight tracking -## Your capabilities: -- Search and retrieve information about VIPs, drivers, vehicles, events, and flights -- CREATE new VIPs, events, and flights -- UPDATE existing events, flights, VIP info (including party size, contact info, roster status), and driver assignments -- DELETE events and flights that are no longer needed -- Assign/reassign drivers and vehicles to events -- Check for scheduling conflicts and identify gaps -- Get VIP itineraries and driver manifests -- Bulk reassign events, send Signal messages/schedules, find available drivers -- Suggest vehicles, audit schedules, analyze workloads +User Role: ${userRole} +${ + userRole === 'DRIVER' + ? '\nNote: This user is a DRIVER. They have read-only access and can only view schedules and events. Do not offer to create, update, or delete data for driver users.' + : '' +} -## IMPORTANT: Use the right tool for the job -- To MODIFY an existing event → use update_event -- To REMOVE an event → use delete_event -- To CHANGE a flight → use update_flight; ADD a flight → create_flight -- To ASSIGN a driver → assign_driver_to_event; vehicle → assign_vehicle_to_event -- To REASSIGN events between drivers → reassign_driver_events (by NAME) -- To UPDATE VIP party size, contacts, or roster status → use update_vip -- To SEND a message to a driver → send_driver_notification_via_signal -- To SEND schedules → bulk_send_driver_schedules -- To FIND available drivers → find_available_drivers_for_timerange -- To AUDIT schedule → identify_scheduling_gaps -- To SUGGEST vehicles → suggest_vehicle_for_event +Guidelines: +1. Always provide clear, actionable information +2. When conflicts are detected, explain them clearly and suggest solutions +3. Use tools to fetch real-time data rather than making assumptions +4. For scheduling tasks, always check for conflicts before confirming +5. Be proactive in identifying potential issues (unassigned events, double-bookings) +6. When dates/times are ambiguous, ask for clarification +7. Keep responses concise but comprehensive -## Party size and companions: -- partySize = the VIP + all companions/handlers/entourage -- If user says "add 20 companions" → set partySize to 21 (VIP + 20) -- Always use update_vip with partySize to change this, not notes +Communication Style: +- Professional and efficient +- Use bullet points for lists +- Highlight important warnings or conflicts +- Provide context when suggesting changes -## CRITICAL: Never ask for IDs - use names! -- You can search for drivers, VIPs, vehicles, and events by NAME -- Most tools accept both name and ID parameters - prefer names -- If a search returns no results, use list_all_drivers to see available driver names -- Always try searching before telling the user you can't find something - -## For actions that MODIFY data (create, update, delete): -1. First, search to find the relevant records (use names, not IDs) -2. Clearly state what changes you're proposing -3. For BULK operations (updating many records, reassigning multiple events, etc.): - - Tell the user upfront: "This is a larger task - I'll be updating X records. Give me a moment to work through them all." - - Then proceed immediately with all the tool calls WITHOUT waiting for confirmation - - Summarize all changes at the end -4. For single-record changes, ask for confirmation before executing -5. After execution, show a summary of what was changed - -## When reassigning a driver's events (driver sick, swapping schedules, etc.): -1. Use reassign_driver_events with the FROM and TO driver names -2. Optionally specify a date to limit the reassignment -3. The tool handles everything - no need to search for IDs first - -## When sending messages to drivers: -1. Use send_driver_notification_via_signal with the driver's name -2. Keep messages clear and actionable -3. For bulk schedule sending, use bulk_send_driver_schedules - -## When processing images (screenshots of emails, itineraries, etc.): -1. Carefully read and extract all relevant information -2. Identify the VIP, flight number, and any changed times -3. Search for the matching VIP and flight in the system -4. Propose the necessary updates and ask for confirmation - -## Response style: -- Be concise but thorough -- Use markdown formatting for readability -- DON'T ask for IDs - use names and search tools to find records -- If you can't find a record, list available options (use list_all_drivers, etc.) -- Always confirm successful actions with a brief summary`; +Available Tools: +You have access to tools for searching VIPs, drivers, events, managing schedules, checking conflicts, and generating reports. Use them to provide accurate, up-to-date information.`; } private async executeTool(name: string, input: Record): Promise { try { switch (name) { + // VIP Operations case 'search_vips': - return await this.searchVips(input); + return await this.vipService.searchVips(input); case 'get_vip_details': - return await this.getVipDetails(input.vipId); - case 'search_drivers': - return await this.searchDrivers(input); - case 'get_driver_schedule': - return await this.getDriverSchedule(input.driverId, input.startDate, input.endDate); - case 'search_events': - return await this.searchEvents(input); - case 'get_available_vehicles': - return await this.getAvailableVehicles(input); - case 'get_flights_for_vip': - return await this.getFlightsForVip(input.vipId); - case 'update_flight': - return await this.updateFlight(input); - case 'create_event': - return await this.createEvent(input); - case 'assign_driver_to_event': - return await this.assignDriverToEvent(input.eventId, input.driverId); - case 'update_event': - return await this.updateEvent(input); - case 'delete_event': - return await this.deleteEvent(input.eventId); - case 'get_todays_summary': - return await this.getTodaysSummary(); - case 'create_flight': - return await this.createFlight(input); - case 'delete_flight': - return await this.deleteFlight(input.flightId); + return await this.vipService.getVipDetails(input.vipId); case 'create_vip': - return await this.createVip(input); + return await this.vipService.createVip(input); case 'update_vip': - return await this.updateVip(input); - case 'assign_vehicle_to_event': - return await this.assignVehicleToEvent(input.eventId, input.vehicleId); - case 'update_driver': - return await this.updateDriver(input); - case 'check_driver_conflicts': - return await this.checkDriverConflicts(input); + return await this.vipService.updateVip(input); case 'get_vip_itinerary': - return await this.getVipItinerary(input); + return await this.vipService.getVipItinerary(input); + + // Flight Operations + case 'get_flights_for_vip': + return await this.vipService.getFlightsForVip(input.vipId); + case 'create_flight': + return await this.vipService.createFlight(input); + case 'update_flight': + return await this.vipService.updateFlight(input); + case 'delete_flight': + return await this.vipService.deleteFlight(input.flightId); + + // Event/Schedule Operations + case 'search_events': + return await this.scheduleService.searchEvents(input); + case 'create_event': + return await this.scheduleService.createEvent(input); + case 'update_event': + return await this.scheduleService.updateEvent(input); + case 'delete_event': + return await this.scheduleService.deleteEvent(input.eventId); + case 'assign_driver_to_event': + return await this.scheduleService.assignDriverToEvent(input.eventId, input.driverId); + case 'check_driver_conflicts': + return await this.scheduleService.checkDriverConflicts(input); case 'reassign_driver_events': - return await this.reassignDriverEvents(input); - case 'list_all_drivers': - return await this.listAllDrivers(input); - case 'find_available_drivers_for_timerange': - return await this.findAvailableDriversForTimerange(input); + return await this.scheduleService.reassignDriverEvents(input); case 'get_daily_driver_manifest': - return await this.getDailyDriverManifest(input); - case 'send_driver_notification_via_signal': - return await this.sendDriverNotificationViaSignal(input); - case 'bulk_send_driver_schedules': - return await this.bulkSendDriverSchedules(input); + return await this.scheduleService.getDailyDriverManifest(input); case 'find_unassigned_events': - return await this.findUnassignedEvents(input); + return await this.scheduleService.findUnassignedEvents(input); case 'check_vip_conflicts': - return await this.checkVipConflicts(input); + return await this.scheduleService.checkVipConflicts(input); case 'get_weekly_lookahead': - return await this.getWeeklyLookahead(input); + return await this.scheduleService.getWeeklyLookahead(input); case 'identify_scheduling_gaps': - return await this.identifySchedulingGaps(input); + return await this.scheduleService.identifySchedulingGaps(input); + case 'send_driver_notification_via_signal': + return await this.scheduleService.sendDriverNotificationViaSignal(input); + case 'bulk_send_driver_schedules': + return await this.scheduleService.bulkSendDriverSchedules(input); + + // Fleet Operations (Drivers & Vehicles) + case 'search_drivers': + return await this.fleetService.searchDrivers(input); + case 'get_driver_schedule': + return await this.fleetService.getDriverSchedule( + input.driverId, + input.startDate, + input.endDate, + ); + case 'list_all_drivers': + return await this.fleetService.listAllDrivers(input); + case 'find_available_drivers_for_timerange': + return await this.fleetService.findAvailableDriversForTimerange(input); + case 'update_driver': + return await this.fleetService.updateDriver(input); + case 'get_available_vehicles': + return await this.fleetService.getAvailableVehicles(input); + case 'assign_vehicle_to_event': + return await this.fleetService.assignVehicleToEvent(input.eventId, input.vehicleId); case 'suggest_vehicle_for_event': - return await this.suggestVehicleForEvent(input); + return await this.fleetService.suggestVehicleForEvent(input); case 'get_vehicle_schedule': - return await this.getVehicleSchedule(input); + return await this.fleetService.getVehicleSchedule(input); + + // Reports Operations + case 'get_todays_summary': + return await this.reportsService.getTodaysSummary(); case 'get_driver_workload_summary': - return await this.getDriverWorkloadSummary(input); + return await this.reportsService.getDriverWorkloadSummary(input); case 'get_current_system_status': - return await this.getCurrentSystemStatus(); + return await this.reportsService.getCurrentSystemStatus(); + default: return { success: false, error: `Unknown tool: ${name}` }; } @@ -767,1986 +897,4 @@ User role: ${userRole} return { success: false, error: error.message }; } } - - // Tool implementations - private async searchVips(filters: Record): Promise { - const where: any = { deletedAt: null }; - - if (filters.name) { - where.name = { contains: filters.name, mode: 'insensitive' }; - } - if (filters.organization) { - where.organization = { contains: filters.organization, mode: 'insensitive' }; - } - if (filters.department) { - where.department = filters.department; - } - if (filters.arrivalMode) { - where.arrivalMode = filters.arrivalMode; - } - - const vips = await this.prisma.vIP.findMany({ - where, - include: { - flights: true, - }, - take: 20, - }); - - // Fetch events for these VIPs - const vipIds = vips.map(v => v.id); - const events = await this.prisma.scheduleEvent.findMany({ - where: { - deletedAt: null, - vipIds: { hasSome: vipIds }, - }, - orderBy: { startTime: 'asc' }, - }); - - // Attach events to VIPs - const vipsWithEvents = vips.map(vip => ({ - ...vip, - events: events.filter(e => e.vipIds.includes(vip.id)).slice(0, 5), - })); - - return { success: true, data: vipsWithEvents }; - } - - private async getVipDetails(vipId: string): Promise { - const vip = await this.prisma.vIP.findUnique({ - where: { id: vipId }, - include: { - flights: true, - }, - }); - - if (!vip) { - return { success: false, error: 'VIP not found' }; - } - - // Fetch events for this VIP - const events = await this.prisma.scheduleEvent.findMany({ - where: { - deletedAt: null, - vipIds: { has: vipId }, - }, - include: { - driver: true, - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }); - - return { success: true, data: { ...vip, events } }; - } - - private async searchDrivers(filters: Record): Promise { - const where: any = { deletedAt: null }; - - if (filters.name) { - where.name = { contains: filters.name, mode: 'insensitive' }; - } - if (filters.department) { - where.department = filters.department; - } - if (filters.availableOnly) { - where.isAvailable = true; - } - - const drivers = await this.prisma.driver.findMany({ - where, - include: { - events: { - where: { - deletedAt: null, - startTime: { gte: new Date() }, - }, - take: 5, - }, - }, - take: 20, - }); - - return { success: true, data: drivers }; - } - - private async getDriverSchedule( - driverId: string, - startDate?: string, - endDate?: string, - ): Promise { - const start = startDate ? new Date(startDate) : new Date(); - const end = endDate ? new Date(endDate) : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - - const driver = await this.prisma.driver.findUnique({ - where: { id: driverId }, - include: { - events: { - where: { - deletedAt: null, - startTime: { gte: start, lte: end }, - }, - include: { - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }, - }, - }); - - if (!driver) { - return { success: false, error: 'Driver not found' }; - } - - // Fetch VIP names for events - const allVipIds = driver.events.flatMap(e => e.vipIds); - const uniqueVipIds = [...new Set(allVipIds)]; - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: uniqueVipIds } }, - select: { id: true, name: true }, - }); - const vipMap = new Map(vips.map(v => [v.id, v.name])); - - const eventsWithVipNames = driver.events.map(event => ({ - ...event, - vipNames: event.vipIds.map(id => vipMap.get(id) || 'Unknown'), - })); - - return { success: true, data: { ...driver, events: eventsWithVipNames } }; - } - - private async searchEvents(filters: Record): Promise { - const where: any = { deletedAt: null }; - - // If searching by VIP name, first find matching VIPs - let vipIdsFromName: string[] = []; - if (filters.vipName) { - const matchingVips = await this.prisma.vIP.findMany({ - where: { - deletedAt: null, - name: { contains: filters.vipName, mode: 'insensitive' }, - }, - select: { id: true }, - }); - vipIdsFromName = matchingVips.map(v => v.id); - if (vipIdsFromName.length === 0) { - return { success: true, data: [], message: `No VIPs found matching "${filters.vipName}"` }; - } - } - - // If searching by driver name, first find matching drivers - let driverIdsFromName: string[] = []; - if (filters.driverName) { - const matchingDrivers = await this.prisma.driver.findMany({ - where: { - deletedAt: null, - name: { contains: filters.driverName, mode: 'insensitive' }, - }, - select: { id: true, name: true }, - }); - driverIdsFromName = matchingDrivers.map(d => d.id); - if (driverIdsFromName.length === 0) { - // List all drivers to help the user find the right name - const allDrivers = await this.prisma.driver.findMany({ - where: { deletedAt: null }, - select: { id: true, name: true }, - orderBy: { name: 'asc' }, - }); - return { - success: true, - data: [], - message: `No drivers found matching "${filters.driverName}". Available drivers: ${allDrivers.map(d => d.name).join(', ')}` - }; - } - } - - if (filters.vipId) { - where.vipIds = { has: filters.vipId }; - } else if (vipIdsFromName.length > 0) { - where.vipIds = { hasSome: vipIdsFromName }; - } - if (filters.title) { - where.title = { contains: filters.title, mode: 'insensitive' }; - } - if (filters.driverId) { - where.driverId = filters.driverId; - } else if (driverIdsFromName.length > 0) { - where.driverId = { in: driverIdsFromName }; - } - if (filters.status) { - where.status = filters.status; - } - if (filters.type) { - where.type = filters.type; - } - if (filters.date) { - const date = new Date(filters.date); - const nextDay = new Date(date); - nextDay.setDate(nextDay.getDate() + 1); - where.startTime = { gte: date, lt: nextDay }; - } - - const events = await this.prisma.scheduleEvent.findMany({ - where, - include: { - driver: true, - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - take: 50, - }); - - // Fetch VIP names for events - const allVipIds = events.flatMap(e => e.vipIds); - const uniqueVipIds = [...new Set(allVipIds)]; - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: uniqueVipIds } }, - select: { id: true, name: true }, - }); - const vipMap = new Map(vips.map(v => [v.id, v.name])); - - const eventsWithVipNames = events.map(event => ({ - ...event, - vipNames: event.vipIds.map(id => vipMap.get(id) || 'Unknown'), - })); - - return { success: true, data: eventsWithVipNames }; - } - - private async getAvailableVehicles(filters: Record): Promise { - const where: any = { deletedAt: null, status: 'AVAILABLE' }; - - if (filters.type) { - where.type = filters.type; - } - if (filters.minSeats) { - where.seatCapacity = { gte: filters.minSeats }; - } - - const vehicles = await this.prisma.vehicle.findMany({ - where, - orderBy: { seatCapacity: 'desc' }, - }); - - return { success: true, data: vehicles }; - } - - private async getFlightsForVip(vipId: string): Promise { - const flights = await this.prisma.flight.findMany({ - where: { vipId }, - orderBy: { flightDate: 'asc' }, - }); - - return { success: true, data: flights }; - } - - private async updateFlight(input: Record): Promise { - const { flightId, ...updateData } = input; - - const flight = await this.prisma.flight.update({ - where: { id: flightId }, - data: updateData, - include: { vip: true }, - }); - - return { success: true, data: flight }; - } - - private async createEvent(input: Record): Promise { - // Support both single vipId and array of vipIds - const vipIds = input.vipIds || (input.vipId ? [input.vipId] : []); - - const event = await this.prisma.scheduleEvent.create({ - data: { - vipIds, - title: input.title, - type: input.type, - startTime: new Date(input.startTime), - endTime: new Date(input.endTime), - location: input.location, - pickupLocation: input.pickupLocation, - dropoffLocation: input.dropoffLocation, - driverId: input.driverId, - vehicleId: input.vehicleId, - description: input.description, - status: 'SCHEDULED', - }, - include: { - driver: true, - vehicle: true, - }, - }); - - // Fetch VIP names - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: vipIds } }, - select: { id: true, name: true }, - }); - - return { success: true, data: { ...event, vipNames: vips.map(v => v.name) } }; - } - - private async assignDriverToEvent(eventId: string, driverId: string): Promise { - const event = await this.prisma.scheduleEvent.update({ - where: { id: eventId }, - data: { driverId }, - include: { - driver: true, - vehicle: true, - }, - }); - - // Fetch VIP names - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: event.vipIds } }, - select: { id: true, name: true }, - }); - - return { success: true, data: { ...event, vipNames: vips.map(v => v.name) } }; - } - - private async updateEvent(input: Record): Promise { - const { eventId, ...updateData } = input; - - // Build update object, only including fields that were provided - const data: any = {}; - if (updateData.title !== undefined) data.title = updateData.title; - if (updateData.startTime !== undefined) data.startTime = new Date(updateData.startTime); - if (updateData.endTime !== undefined) data.endTime = new Date(updateData.endTime); - if (updateData.location !== undefined) data.location = updateData.location; - if (updateData.pickupLocation !== undefined) data.pickupLocation = updateData.pickupLocation; - if (updateData.dropoffLocation !== undefined) data.dropoffLocation = updateData.dropoffLocation; - if (updateData.status !== undefined) data.status = updateData.status; - if (updateData.driverId !== undefined) data.driverId = updateData.driverId === 'null' ? null : updateData.driverId; - if (updateData.vehicleId !== undefined) data.vehicleId = updateData.vehicleId === 'null' ? null : updateData.vehicleId; - if (updateData.description !== undefined) data.description = updateData.description; - - const event = await this.prisma.scheduleEvent.update({ - where: { id: eventId }, - data, - include: { - driver: true, - vehicle: true, - }, - }); - - // Fetch VIP names - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: event.vipIds } }, - select: { id: true, name: true }, - }); - - return { success: true, data: { ...event, vipNames: vips.map(v => v.name) } }; - } - - private async deleteEvent(eventId: string): Promise { - // First get the event to return info about what was deleted - const event = await this.prisma.scheduleEvent.findUnique({ - where: { id: eventId }, - include: { - driver: true, - vehicle: true, - }, - }); - - if (!event) { - return { success: false, error: 'Event not found' }; - } - - // Soft delete - await this.prisma.scheduleEvent.update({ - where: { id: eventId }, - data: { deletedAt: new Date() }, - }); - - // Fetch VIP names - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: event.vipIds } }, - select: { id: true, name: true }, - }); - - return { - success: true, - data: { - deleted: true, - event: { ...event, vipNames: vips.map(v => v.name) } - } - }; - } - - private async getTodaysSummary(): Promise { - const today = startOfDay(new Date()); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - const [events, arrivingVips, availableDrivers, availableVehicles] = await Promise.all([ - this.prisma.scheduleEvent.findMany({ - where: { - deletedAt: null, - startTime: { gte: today, lt: tomorrow }, - }, - include: { - driver: true, - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }), - this.prisma.vIP.findMany({ - where: { - deletedAt: null, - OR: [ - { expectedArrival: { gte: today, lt: tomorrow } }, - { - flights: { - some: { - scheduledArrival: { gte: today, lt: tomorrow }, - }, - }, - }, - ], - }, - include: { flights: true }, - }), - this.prisma.driver.findMany({ - where: { deletedAt: null, isAvailable: true }, - }), - this.prisma.vehicle.findMany({ - where: { deletedAt: null, status: 'AVAILABLE' }, - }), - ]); - - // Fetch VIP names for events - const allVipIds = events.flatMap(e => e.vipIds); - const uniqueVipIds = [...new Set(allVipIds)]; - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: uniqueVipIds } }, - select: { id: true, name: true }, - }); - const vipMap = new Map(vips.map(v => [v.id, v.name])); - - const eventsWithVipNames = events.map(event => ({ - ...event, - vipNames: event.vipIds.map(id => vipMap.get(id) || 'Unknown'), - })); - - return { - success: true, - data: { - todaysEvents: eventsWithVipNames, - arrivingVips, - availableDrivers: availableDrivers.length, - availableVehicles: availableVehicles.length, - summary: { - totalEvents: events.length, - eventsWithoutDriver: events.filter(e => !e.driverId).length, - eventsWithoutVehicle: events.filter(e => !e.vehicleId).length, - }, - }, - }; - } - - private async createFlight(input: Record): Promise { - const flight = await this.prisma.flight.create({ - data: { - vipId: input.vipId, - flightNumber: input.flightNumber, - flightDate: new Date(input.flightDate), - departureAirport: input.departureAirport, - arrivalAirport: input.arrivalAirport, - scheduledDeparture: input.scheduledDeparture ? new Date(input.scheduledDeparture) : null, - scheduledArrival: input.scheduledArrival ? new Date(input.scheduledArrival) : null, - segment: input.segment || 1, - }, - include: { vip: true }, - }); - - return { success: true, data: flight }; - } - - private async deleteFlight(flightId: string): Promise { - const flight = await this.prisma.flight.findUnique({ - where: { id: flightId }, - include: { vip: true }, - }); - - if (!flight) { - return { success: false, error: 'Flight not found' }; - } - - await this.prisma.flight.delete({ - where: { id: flightId }, - }); - - return { - success: true, - data: { deleted: true, flight }, - }; - } - - private async createVip(input: Record): Promise { - const vip = await this.prisma.vIP.create({ - data: { - name: input.name, - organization: input.organization, - department: input.department, - arrivalMode: input.arrivalMode, - expectedArrival: input.expectedArrival ? new Date(input.expectedArrival) : null, - airportPickup: input.airportPickup ?? false, - venueTransport: input.venueTransport ?? false, - partySize: input.partySize ?? 1, - notes: input.notes, - isRosterOnly: input.isRosterOnly ?? false, - phone: input.phone || null, - email: input.email || null, - emergencyContactName: input.emergencyContactName || null, - emergencyContactPhone: input.emergencyContactPhone || null, - }, - }); - - return { success: true, data: vip }; - } - - private async updateVip(input: Record): Promise { - const { vipId, ...updateData } = input; - - const data: any = {}; - if (updateData.name !== undefined) data.name = updateData.name; - if (updateData.organization !== undefined) data.organization = updateData.organization; - if (updateData.department !== undefined) data.department = updateData.department; - if (updateData.arrivalMode !== undefined) data.arrivalMode = updateData.arrivalMode; - if (updateData.expectedArrival !== undefined) data.expectedArrival = updateData.expectedArrival ? new Date(updateData.expectedArrival) : null; - if (updateData.airportPickup !== undefined) data.airportPickup = updateData.airportPickup; - if (updateData.venueTransport !== undefined) data.venueTransport = updateData.venueTransport; - if (updateData.partySize !== undefined) data.partySize = updateData.partySize; - if (updateData.notes !== undefined) data.notes = updateData.notes; - if (updateData.isRosterOnly !== undefined) data.isRosterOnly = updateData.isRosterOnly; - if (updateData.phone !== undefined) data.phone = updateData.phone || null; - if (updateData.email !== undefined) data.email = updateData.email || null; - if (updateData.emergencyContactName !== undefined) data.emergencyContactName = updateData.emergencyContactName || null; - if (updateData.emergencyContactPhone !== undefined) data.emergencyContactPhone = updateData.emergencyContactPhone || null; - - const vip = await this.prisma.vIP.update({ - where: { id: vipId }, - data, - include: { flights: true }, - }); - - return { success: true, data: vip }; - } - - private async assignVehicleToEvent(eventId: string, vehicleId: string): Promise { - const event = await this.prisma.scheduleEvent.update({ - where: { id: eventId }, - data: { vehicleId: vehicleId === 'null' ? null : vehicleId }, - include: { - driver: true, - vehicle: true, - }, - }); - - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: event.vipIds } }, - select: { id: true, name: true }, - }); - - return { success: true, data: { ...event, vipNames: vips.map(v => v.name) } }; - } - - private async updateDriver(input: Record): Promise { - const { driverId, ...updateData } = input; - - const data: any = {}; - if (updateData.name !== undefined) data.name = updateData.name; - if (updateData.phone !== undefined) data.phone = updateData.phone; - if (updateData.department !== undefined) data.department = updateData.department; - if (updateData.isAvailable !== undefined) data.isAvailable = updateData.isAvailable; - if (updateData.shiftStartTime !== undefined) data.shiftStartTime = updateData.shiftStartTime; - if (updateData.shiftEndTime !== undefined) data.shiftEndTime = updateData.shiftEndTime; - - const driver = await this.prisma.driver.update({ - where: { id: driverId }, - data, - }); - - return { success: true, data: driver }; - } - - private async checkDriverConflicts(input: Record): Promise { - const { driverId, startTime, endTime, excludeEventId } = input; - - const start = new Date(startTime); - const end = new Date(endTime); - - const where: any = { - deletedAt: null, - driverId, - status: { not: 'CANCELLED' }, - OR: [ - // Event starts during the time period - { startTime: { gte: start, lt: end } }, - // Event ends during the time period - { endTime: { gt: start, lte: end } }, - // Event spans the entire time period - { AND: [{ startTime: { lte: start } }, { endTime: { gte: end } }] }, - ], - }; - - if (excludeEventId) { - where.id = { not: excludeEventId }; - } - - const conflictingEvents = await this.prisma.scheduleEvent.findMany({ - where, - include: { vehicle: true }, - orderBy: { startTime: 'asc' }, - }); - - // Fetch VIP names - const allVipIds = conflictingEvents.flatMap(e => e.vipIds); - const uniqueVipIds = [...new Set(allVipIds)]; - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: uniqueVipIds } }, - select: { id: true, name: true }, - }); - const vipMap = new Map(vips.map(v => [v.id, v.name])); - - const eventsWithVipNames = conflictingEvents.map(event => ({ - ...event, - vipNames: event.vipIds.map(id => vipMap.get(id) || 'Unknown'), - })); - - return { - success: true, - data: { - hasConflicts: conflictingEvents.length > 0, - conflictCount: conflictingEvents.length, - conflicts: eventsWithVipNames, - }, - }; - } - - private async getVipItinerary(input: Record): Promise { - const { vipId, startDate, endDate } = input; - - const vip = await this.prisma.vIP.findUnique({ - where: { id: vipId }, - }); - - if (!vip) { - return { success: false, error: 'VIP not found' }; - } - - // Build date filters - const dateFilter: any = {}; - if (startDate) dateFilter.gte = new Date(startDate); - if (endDate) dateFilter.lte = new Date(endDate); - - // Get flights - const flightsWhere: any = { vipId }; - if (startDate || endDate) { - flightsWhere.flightDate = dateFilter; - } - const flights = await this.prisma.flight.findMany({ - where: flightsWhere, - orderBy: { scheduledDeparture: 'asc' }, - }); - - // Get events - const eventsWhere: any = { - deletedAt: null, - vipIds: { has: vipId }, - }; - if (startDate || endDate) { - eventsWhere.startTime = dateFilter; - } - const events = await this.prisma.scheduleEvent.findMany({ - where: eventsWhere, - include: { - driver: true, - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }); - - // Combine and sort chronologically - const itineraryItems: any[] = [ - ...flights.map(f => ({ - type: 'FLIGHT', - time: f.scheduledDeparture || f.flightDate, - data: f, - })), - ...events.map(e => ({ - type: 'EVENT', - time: e.startTime, - data: e, - })), - ].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); - - return { - success: true, - data: { - vip, - itinerary: itineraryItems, - summary: { - totalFlights: flights.length, - totalEvents: events.length, - }, - }, - }; - } - - private async reassignDriverEvents(input: Record): Promise { - const { fromDriverName, toDriverName, date, onlyStatus } = input; - - // Find the source driver by name - const fromDrivers = await this.prisma.driver.findMany({ - where: { - deletedAt: null, - name: { contains: fromDriverName, mode: 'insensitive' }, - }, - }); - - if (fromDrivers.length === 0) { - const allDrivers = await this.prisma.driver.findMany({ - where: { deletedAt: null }, - select: { name: true }, - orderBy: { name: 'asc' }, - }); - return { - success: false, - error: `No driver found matching "${fromDriverName}". Available drivers: ${allDrivers.map(d => d.name).join(', ')}` - }; - } - - if (fromDrivers.length > 1) { - return { - success: false, - error: `Multiple drivers match "${fromDriverName}": ${fromDrivers.map(d => d.name).join(', ')}. Please be more specific.` - }; - } - - const fromDriver = fromDrivers[0]; - - // Find the target driver by name - const toDrivers = await this.prisma.driver.findMany({ - where: { - deletedAt: null, - name: { contains: toDriverName, mode: 'insensitive' }, - }, - }); - - if (toDrivers.length === 0) { - const allDrivers = await this.prisma.driver.findMany({ - where: { deletedAt: null }, - select: { name: true }, - orderBy: { name: 'asc' }, - }); - return { - success: false, - error: `No driver found matching "${toDriverName}". Available drivers: ${allDrivers.map(d => d.name).join(', ')}` - }; - } - - if (toDrivers.length > 1) { - return { - success: false, - error: `Multiple drivers match "${toDriverName}": ${toDrivers.map(d => d.name).join(', ')}. Please be more specific.` - }; - } - - const toDriver = toDrivers[0]; - - // Build query for events to reassign - const where: any = { - deletedAt: null, - driverId: fromDriver.id, - status: { not: 'CANCELLED' }, - }; - - // Filter by date if provided - if (date) { - const targetDate = new Date(date); - const nextDay = new Date(targetDate); - nextDay.setDate(nextDay.getDate() + 1); - where.startTime = { gte: targetDate, lt: nextDay }; - } else { - // Only future events by default - where.startTime = { gte: new Date() }; - } - - if (onlyStatus) { - where.status = onlyStatus; - } - - // Get events to reassign - const eventsToReassign = await this.prisma.scheduleEvent.findMany({ - where, - include: { vehicle: true }, - orderBy: { startTime: 'asc' }, - }); - - if (eventsToReassign.length === 0) { - return { - success: true, - data: { - reassigned: 0, - fromDriver: fromDriver.name, - toDriver: toDriver.name, - }, - message: `No events found for ${fromDriver.name} to reassign.` - }; - } - - // Reassign all events - await this.prisma.scheduleEvent.updateMany({ - where: { id: { in: eventsToReassign.map(e => e.id) } }, - data: { driverId: toDriver.id }, - }); - - // Fetch VIP names for the events - const allVipIds = eventsToReassign.flatMap(e => e.vipIds); - const uniqueVipIds = [...new Set(allVipIds)]; - const vips = await this.prisma.vIP.findMany({ - where: { id: { in: uniqueVipIds } }, - select: { id: true, name: true }, - }); - const vipMap = new Map(vips.map(v => [v.id, v.name])); - - const eventsWithDetails = eventsToReassign.map(event => ({ - id: event.id, - title: event.title, - startTime: event.startTime, - endTime: event.endTime, - vipNames: event.vipIds.map(id => vipMap.get(id) || 'Unknown'), - })); - - return { - success: true, - data: { - reassigned: eventsToReassign.length, - fromDriver: fromDriver.name, - toDriver: toDriver.name, - events: eventsWithDetails, - }, - message: `Successfully reassigned ${eventsToReassign.length} event(s) from ${fromDriver.name} to ${toDriver.name}.` - }; - } - - private async listAllDrivers(input: Record): Promise { - const includeUnavailable = input.includeUnavailable !== false; - - const where: any = { deletedAt: null }; - if (!includeUnavailable) { - where.isAvailable = true; - } - - const drivers = await this.prisma.driver.findMany({ - where, - select: { - id: true, - name: true, - phone: true, - department: true, - isAvailable: true, - _count: { - select: { - events: { - where: { - deletedAt: null, - startTime: { gte: new Date() }, - status: { not: 'CANCELLED' }, - }, - }, - }, - }, - }, - orderBy: { name: 'asc' }, - }); - - const driversWithInfo = drivers.map(d => ({ - id: d.id, - name: d.name, - phone: d.phone, - department: d.department, - isAvailable: d.isAvailable, - upcomingEventCount: d._count.events, - })); - - return { - success: true, - data: driversWithInfo, - message: `Found ${drivers.length} driver(s).` - }; - } - - // ============================================ - // NEW TOOLS - HIGH PRIORITY (5) - // ============================================ - - private async findAvailableDriversForTimerange(input: Record): Promise { - const { startTime, endTime, preferredDepartment } = input; - - const start = new Date(startTime); - const end = new Date(endTime); - - // Get all drivers - const where: any = { deletedAt: null, isAvailable: true }; - if (preferredDepartment) { - where.department = preferredDepartment; - } - - const drivers = await this.prisma.driver.findMany({ - where, - include: { - events: { - where: { - deletedAt: null, - status: { not: 'CANCELLED' }, - OR: [ - { startTime: { gte: start, lt: end } }, - { endTime: { gt: start, lte: end } }, - { AND: [{ startTime: { lte: start } }, { endTime: { gte: end } }] }, - ], - }, - }, - }, - }); - - // Filter to only drivers with no conflicting events - const availableDrivers = drivers - .filter(d => d.events.length === 0) - .map(d => ({ - id: d.id, - name: d.name, - phone: d.phone, - department: d.department, - shiftStartTime: d.shiftStartTime, - shiftEndTime: d.shiftEndTime, - })); - - return { - success: true, - data: availableDrivers, - message: `Found ${availableDrivers.length} available driver(s) for ${start.toLocaleString()} - ${end.toLocaleString()}`, - }; - } - - private async getDailyDriverManifest(input: Record): Promise { - const { driverName, driverId, date } = input; - - // Find driver by name or ID - let driver; - if (driverId) { - driver = await this.prisma.driver.findUnique({ where: { id: driverId } }); - } else if (driverName) { - const drivers = await this.prisma.driver.findMany({ - where: { - deletedAt: null, - name: { contains: driverName, mode: 'insensitive' }, - }, - }); - - if (drivers.length === 0) { - const allDrivers = await this.prisma.driver.findMany({ - where: { deletedAt: null }, - select: { name: true }, - orderBy: { name: 'asc' }, - }); - return { - success: false, - error: `No driver found matching "${driverName}". Available drivers: ${allDrivers.map(d => d.name).join(', ')}`, - }; - } - - if (drivers.length > 1) { - return { - success: false, - error: `Multiple drivers match "${driverName}": ${drivers.map(d => d.name).join(', ')}. Please be more specific.`, - }; - } - - driver = drivers[0]; - } else { - return { success: false, error: 'Either driverName or driverId is required' }; - } - - if (!driver) { - return { success: false, error: 'Driver not found' }; - } - - // Parse date or default to today - const targetDate = date ? new Date(date) : new Date(); - 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: dayStart, lte: dayEnd }, - status: { not: 'CANCELLED' }, - }, - include: { - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }); - - // Fetch VIP names - 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])); - - // Build manifest with gaps - const manifest = events.map((event, index) => { - const eventData: any = { - id: event.id, - title: event.title, - type: event.type, - status: event.status, - startTime: event.startTime, - endTime: event.endTime, - vipNames: event.vipIds.map(id => vipMap.get(id) || 'Unknown'), - pickupLocation: event.pickupLocation, - dropoffLocation: event.dropoffLocation, - location: event.location, - vehicle: event.vehicle ? { - name: event.vehicle.name, - licensePlate: event.vehicle.licensePlate, - type: event.vehicle.type, - seatCapacity: event.vehicle.seatCapacity, - } : null, - notes: event.notes, - }; - - // Calculate gap to next event - if (index < events.length - 1) { - const nextEvent = events[index + 1]; - const gapMinutes = Math.round( - (nextEvent.startTime.getTime() - event.endTime.getTime()) / 60000 - ); - eventData.gapToNextEvent = { - minutes: gapMinutes, - formatted: `${Math.floor(gapMinutes / 60)}h ${gapMinutes % 60}m`, - }; - } - - return eventData; - }); - - return { - success: true, - data: { - driver: { - id: driver.id, - name: driver.name, - phone: driver.phone, - department: driver.department, - shiftStartTime: driver.shiftStartTime, - shiftEndTime: driver.shiftEndTime, - }, - date: toDateString(targetDate), - eventCount: events.length, - manifest, - }, - message: `Manifest for ${driver.name} on ${targetDate.toLocaleDateString()}: ${events.length} event(s)`, - }; - } - - private async sendDriverNotificationViaSignal(input: Record): Promise { - const { driverName, driverId, message, relatedEventId } = input; - - // Find driver by name or ID - let driver; - if (driverId) { - driver = await this.prisma.driver.findUnique({ where: { id: driverId } }); - } else if (driverName) { - const drivers = await this.prisma.driver.findMany({ - where: { - deletedAt: null, - name: { contains: driverName, mode: 'insensitive' }, - }, - }); - - if (drivers.length === 0) { - const allDrivers = await this.prisma.driver.findMany({ - where: { deletedAt: null }, - select: { name: true }, - orderBy: { name: 'asc' }, - }); - return { - success: false, - error: `No driver found matching "${driverName}". Available drivers: ${allDrivers.map(d => d.name).join(', ')}`, - }; - } - - if (drivers.length > 1) { - return { - success: false, - error: `Multiple drivers match "${driverName}": ${drivers.map(d => d.name).join(', ')}. Please be more specific.`, - }; - } - - driver = drivers[0]; - } else { - return { success: false, error: 'Either driverName or driverId is required' }; - } - - if (!driver) { - return { success: false, error: 'Driver not found' }; - } - - // Send message via MessagesService - try { - const result = await this.messagesService.sendMessage({ - driverId: driver.id, - content: message, - }); - - return { - success: true, - data: { - messageId: result.id, - driverId: driver.id, - driverName: driver.name, - messageSent: true, - timestamp: result.timestamp, - relatedEventId, - }, - message: `Message sent to ${driver.name} via Signal`, - }; - } catch (error) { - return { - success: false, - error: `Failed to send message: ${error.message}`, - }; - } - } - - private async bulkSendDriverSchedules(input: Record): Promise { - const { date, driverNames } = input; - - const targetDate = new Date(date); - 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: dayStart, lte: dayEnd }, - status: { not: 'CANCELLED' }, - driverId: { not: null }, - }, - select: { driverId: true }, - }); - - const driverIdsWithEvents = [...new Set(eventsOnDate.map(e => e.driverId).filter((id): id is string => id !== null))]; - - // Filter by names if provided - let targetDrivers; - if (driverNames && driverNames.length > 0) { - targetDrivers = await this.prisma.driver.findMany({ - where: { - deletedAt: null, - id: { in: driverIdsWithEvents }, - name: { in: driverNames }, - }, - }); - } else { - targetDrivers = await this.prisma.driver.findMany({ - where: { - deletedAt: null, - id: { in: driverIdsWithEvents }, - }, - }); - } - - const results = []; - const errors = []; - - for (const driver of targetDrivers) { - try { - const result = await this.scheduleExportService.sendScheduleToDriver( - driver.id, - targetDate, - 'both', - ); - results.push({ - driverId: driver.id, - driverName: driver.name, - success: result.success, - message: result.message, - }); - } catch (error) { - errors.push({ - driverId: driver.id, - driverName: driver.name, - error: error.message, - }); - } - } - - return { - success: true, - data: { - date: toDateString(targetDate), - totalDrivers: targetDrivers.length, - successful: results.length, - failed: errors.length, - results, - errors, - }, - message: `Sent schedules to ${results.length} driver(s) for ${targetDate.toLocaleDateString()}. ${errors.length} failed.`, - }; - } - - private async findUnassignedEvents(input: Record): Promise { - const { startDate, endDate, missingDriver = true, missingVehicle = true } = input; - - const start = new Date(startDate); - const end = new Date(endDate); - - const where: any = { - deletedAt: null, - status: { not: 'CANCELLED' }, - startTime: { gte: start, lte: end }, - OR: [], - }; - - if (missingDriver) { - where.OR.push({ driverId: null }); - } - if (missingVehicle) { - where.OR.push({ vehicleId: null }); - } - - if (where.OR.length === 0) { - return { - success: false, - error: 'At least one of missingDriver or missingVehicle must be true', - }; - } - - const events = await this.prisma.scheduleEvent.findMany({ - where, - include: { - driver: true, - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }); - - // Fetch VIP names - 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])); - - const eventsWithDetails = events.map(event => ({ - id: event.id, - title: event.title, - type: event.type, - startTime: event.startTime, - endTime: event.endTime, - vipNames: event.vipIds.map(id => vipMap.get(id) || 'Unknown'), - driverId: event.driverId, - driverName: event.driver?.name || null, - vehicleId: event.vehicleId, - vehicleName: event.vehicle?.name || null, - missingDriver: !event.driverId, - missingVehicle: !event.vehicleId, - location: event.location, - pickupLocation: event.pickupLocation, - dropoffLocation: event.dropoffLocation, - })); - - return { - success: true, - data: { - totalUnassigned: events.length, - missingDriverCount: eventsWithDetails.filter(e => e.missingDriver).length, - missingVehicleCount: eventsWithDetails.filter(e => e.missingVehicle).length, - events: eventsWithDetails, - }, - message: `Found ${events.length} event(s) needing attention between ${start.toLocaleDateString()} and ${end.toLocaleDateString()}`, - }; - } - - // ============================================ - // NEW TOOLS - MEDIUM PRIORITY (6) - // ============================================ - - private async checkVipConflicts(input: Record): Promise { - const { vipName, vipId, startTime, endTime, excludeEventId } = input; - - // Find VIP by name or ID - let vip; - if (vipId) { - vip = await this.prisma.vIP.findUnique({ where: { id: vipId } }); - } else if (vipName) { - const vips = await this.prisma.vIP.findMany({ - where: { - deletedAt: null, - name: { contains: vipName, mode: 'insensitive' }, - }, - }); - - if (vips.length === 0) { - return { - success: false, - error: `No VIP found matching "${vipName}"`, - }; - } - - if (vips.length > 1) { - return { - success: false, - error: `Multiple VIPs match "${vipName}": ${vips.map(v => v.name).join(', ')}. Please be more specific.`, - }; - } - - vip = vips[0]; - } else { - return { success: false, error: 'Either vipName or vipId is required' }; - } - - if (!vip) { - return { success: false, error: 'VIP not found' }; - } - - const start = new Date(startTime); - const end = new Date(endTime); - - const where: any = { - deletedAt: null, - vipIds: { has: vip.id }, - status: { not: 'CANCELLED' }, - OR: [ - { startTime: { gte: start, lt: end } }, - { endTime: { gt: start, lte: end } }, - { AND: [{ startTime: { lte: start } }, { endTime: { gte: end } }] }, - ], - }; - - if (excludeEventId) { - where.id = { not: excludeEventId }; - } - - const conflictingEvents = await this.prisma.scheduleEvent.findMany({ - where, - include: { - driver: true, - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }); - - return { - success: true, - data: { - vip: { - id: vip.id, - name: vip.name, - }, - hasConflicts: conflictingEvents.length > 0, - conflictCount: conflictingEvents.length, - conflicts: conflictingEvents.map(event => ({ - id: event.id, - title: event.title, - startTime: event.startTime, - endTime: event.endTime, - driver: event.driver?.name || null, - vehicle: event.vehicle?.name || null, - })), - }, - message: conflictingEvents.length > 0 - ? `VIP ${vip.name} has ${conflictingEvents.length} conflicting event(s)` - : `No conflicts found for VIP ${vip.name}`, - }; - } - - private async getWeeklyLookahead(input: Record): Promise { - const { startDate, weeksAhead = 1 } = input; - - const start = startOfDay(startDate ? new Date(startDate) : new Date()); - - const end = new Date(start); - end.setDate(end.getDate() + (weeksAhead * 7)); - - // Get all events in range - const events = await this.prisma.scheduleEvent.findMany({ - where: { - deletedAt: null, - startTime: { gte: start, lt: end }, - status: { not: 'CANCELLED' }, - }, - include: { - driver: true, - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }); - - // Get VIP arrivals - const vipArrivals = await this.prisma.vIP.findMany({ - where: { - deletedAt: null, - OR: [ - { expectedArrival: { gte: start, lt: end } }, - { - flights: { - some: { - scheduledArrival: { gte: start, lt: end }, - }, - }, - }, - ], - }, - include: { - flights: { - where: { - scheduledArrival: { gte: start, lt: end }, - }, - }, - }, - }); - - // Group by day - const dayMap = new Map(); - - for (let d = new Date(start); d < end; d.setDate(d.getDate() + 1)) { - const dateKey = toDateString(d); - const dayStart = startOfDay(d); - const dayEnd = new Date(d); - dayEnd.setHours(23, 59, 59, 999); - - const dayEvents = events.filter( - e => e.startTime >= dayStart && e.startTime <= dayEnd - ); - - const dayArrivals = vipArrivals.filter(v => { - if (v.expectedArrival && v.expectedArrival >= dayStart && v.expectedArrival <= dayEnd) { - return true; - } - return v.flights.some(f => f.scheduledArrival && f.scheduledArrival >= dayStart && f.scheduledArrival <= dayEnd); - }); - - dayMap.set(dateKey, { - date: dateKey, - dayOfWeek: d.toLocaleDateString('en-US', { weekday: 'long' }), - eventCount: dayEvents.length, - unassignedCount: dayEvents.filter(e => !e.driverId || !e.vehicleId).length, - arrivingVipCount: dayArrivals.length, - arrivingVips: dayArrivals.map(v => v.name), - }); - } - - return { - success: true, - data: { - startDate: toDateString(start), - endDate: toDateString(end), - weeksAhead, - days: Array.from(dayMap.values()), - summary: { - totalEvents: events.length, - totalUnassigned: events.filter(e => !e.driverId || !e.vehicleId).length, - totalArrivingVips: vipArrivals.length, - }, - }, - }; - } - - private async identifySchedulingGaps(input: Record): Promise { - const { lookaheadDays = 7 } = input; - - const start = startOfDay(new Date()); - const end = new Date(start); - end.setDate(end.getDate() + lookaheadDays); - - // Get all events - const events = await this.prisma.scheduleEvent.findMany({ - where: { - deletedAt: null, - startTime: { gte: start, lt: end }, - status: { not: 'CANCELLED' }, - }, - include: { - driver: true, - vehicle: true, - }, - orderBy: { startTime: 'asc' }, - }); - - // Fetch VIP names - 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])); - - // Find unassigned events - const unassignedEvents = events - .filter(e => !e.driverId || !e.vehicleId) - .map(e => ({ - id: e.id, - title: e.title, - startTime: e.startTime, - vipNames: e.vipIds.map(id => vipMap.get(id) || 'Unknown'), - missingDriver: !e.driverId, - missingVehicle: !e.vehicleId, - })); - - // Find driver conflicts - const driverConflicts: any[] = []; - const driverEventMap = new Map(); - - events.forEach(event => { - if (!event.driverId) return; - if (!driverEventMap.has(event.driverId)) { - driverEventMap.set(event.driverId, []); - } - driverEventMap.get(event.driverId)!.push(event); - }); - - driverEventMap.forEach((driverEvents, driverId) => { - for (let i = 0; i < driverEvents.length - 1; i++) { - const current = driverEvents[i]; - const next = driverEvents[i + 1]; - - if (current.endTime > next.startTime) { - driverConflicts.push({ - driverId, - driverName: current.driver?.name || 'Unknown', - event1: { - id: current.id, - title: current.title, - time: `${current.startTime.toLocaleTimeString()} - ${current.endTime.toLocaleTimeString()}`, - }, - event2: { - id: next.id, - title: next.title, - time: `${next.startTime.toLocaleTimeString()} - ${next.endTime.toLocaleTimeString()}`, - }, - }); - } - } - }); - - // Find VIP conflicts - const vipConflicts: any[] = []; - const vipEventMap = new Map(); - - events.forEach(event => { - event.vipIds.forEach(vipId => { - if (!vipEventMap.has(vipId)) { - vipEventMap.set(vipId, []); - } - vipEventMap.get(vipId)!.push(event); - }); - }); - - vipEventMap.forEach((vipEvents, vipId) => { - const sortedEvents = vipEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); - - for (let i = 0; i < sortedEvents.length - 1; i++) { - const current = sortedEvents[i]; - const next = sortedEvents[i + 1]; - - if (current.endTime > next.startTime) { - vipConflicts.push({ - vipId, - vipName: vipMap.get(vipId) || 'Unknown', - event1: { - id: current.id, - title: current.title, - time: `${current.startTime.toLocaleTimeString()} - ${current.endTime.toLocaleTimeString()}`, - }, - event2: { - id: next.id, - title: next.title, - time: `${next.startTime.toLocaleTimeString()} - ${next.endTime.toLocaleTimeString()}`, - }, - }); - } - } - }); - - return { - success: true, - data: { - dateRange: { - start: toDateString(start), - end: toDateString(end), - }, - totalEvents: events.length, - issues: { - unassignedEvents: { - count: unassignedEvents.length, - events: unassignedEvents, - }, - driverConflicts: { - count: driverConflicts.length, - conflicts: driverConflicts, - }, - vipConflicts: { - count: vipConflicts.length, - conflicts: vipConflicts, - }, - }, - summary: `Found ${unassignedEvents.length} unassigned events, ${driverConflicts.length} driver conflicts, and ${vipConflicts.length} VIP conflicts in the next ${lookaheadDays} days.`, - }, - }; - } - - private async suggestVehicleForEvent(input: Record): Promise { - const { eventId } = input; - - const event = await this.prisma.scheduleEvent.findUnique({ - where: { id: eventId }, - include: { vehicle: true }, - }); - - if (!event) { - return { success: false, error: 'Event not found' }; - } - - // Get VIP count for capacity calculation - const vipCount = event.vipIds.length; - - // Get all vehicles - const vehicles = await this.prisma.vehicle.findMany({ - where: { - deletedAt: null, - status: { in: ['AVAILABLE', 'RESERVED'] }, - }, - }); - - // Check availability for each vehicle - const suggestions = []; - - for (const vehicle of vehicles) { - // Check if vehicle has conflicting events - const conflictingEvents = await this.prisma.scheduleEvent.findMany({ - where: { - vehicleId: vehicle.id, - deletedAt: null, - status: { not: 'CANCELLED' }, - id: { not: eventId }, - OR: [ - { startTime: { gte: event.startTime, lt: event.endTime } }, - { endTime: { gt: event.startTime, lte: event.endTime } }, - { AND: [{ startTime: { lte: event.startTime } }, { endTime: { gte: event.endTime } }] }, - ], - }, - }); - - const isAvailable = conflictingEvents.length === 0; - const hasCapacity = vehicle.seatCapacity >= vipCount; - - suggestions.push({ - id: vehicle.id, - name: vehicle.name, - type: vehicle.type, - seatCapacity: vehicle.seatCapacity, - licensePlate: vehicle.licensePlate, - status: vehicle.status, - isAvailable, - hasCapacity, - score: (isAvailable ? 10 : 0) + (hasCapacity ? 5 : 0) + (vehicle.status === 'AVAILABLE' ? 3 : 0), - }); - } - - // Sort by score (best matches first) - suggestions.sort((a, b) => b.score - a.score); - - return { - success: true, - data: { - event: { - id: event.id, - title: event.title, - startTime: event.startTime, - endTime: event.endTime, - vipCount, - currentVehicle: event.vehicle?.name || null, - }, - suggestions, - recommended: suggestions.filter(s => s.isAvailable && s.hasCapacity), - }, - message: `Found ${suggestions.filter(s => s.isAvailable && s.hasCapacity).length} suitable vehicle(s) for event "${event.title}"`, - }; - } - - private async getVehicleSchedule(input: Record): Promise { - const { vehicleName, vehicleId, startDate, endDate } = input; - - // Find vehicle by name or ID - let vehicle; - if (vehicleId) { - vehicle = await this.prisma.vehicle.findUnique({ where: { id: vehicleId } }); - } else if (vehicleName) { - const vehicles = await this.prisma.vehicle.findMany({ - where: { - deletedAt: null, - name: { contains: vehicleName, mode: 'insensitive' }, - }, - }); - - if (vehicles.length === 0) { - const allVehicles = await this.prisma.vehicle.findMany({ - where: { deletedAt: null }, - select: { name: true }, - orderBy: { name: 'asc' }, - }); - return { - success: false, - error: `No vehicle found matching "${vehicleName}". Available vehicles: ${allVehicles.map(v => v.name).join(', ')}`, - }; - } - - if (vehicles.length > 1) { - return { - success: false, - error: `Multiple vehicles match "${vehicleName}": ${vehicles.map(v => v.name).join(', ')}. Please be more specific.`, - }; - } - - vehicle = vehicles[0]; - } else { - return { success: false, error: 'Either vehicleName or vehicleId is required' }; - } - - if (!vehicle) { - return { success: false, error: 'Vehicle not found' }; - } - - const start = new Date(startDate); - const end = new Date(endDate); - - const events = await this.prisma.scheduleEvent.findMany({ - where: { - vehicleId: vehicle.id, - deletedAt: null, - startTime: { gte: start, lte: end }, - status: { not: 'CANCELLED' }, - }, - include: { - driver: true, - }, - orderBy: { startTime: 'asc' }, - }); - - // Fetch VIP names - 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])); - - const eventsWithDetails = events.map(event => ({ - id: event.id, - title: event.title, - type: event.type, - status: event.status, - startTime: event.startTime, - endTime: event.endTime, - vipNames: event.vipIds.map(id => vipMap.get(id) || 'Unknown'), - driverName: event.driver?.name || null, - pickupLocation: event.pickupLocation, - dropoffLocation: event.dropoffLocation, - location: event.location, - })); - - return { - success: true, - data: { - vehicle: { - id: vehicle.id, - name: vehicle.name, - type: vehicle.type, - licensePlate: vehicle.licensePlate, - seatCapacity: vehicle.seatCapacity, - status: vehicle.status, - }, - dateRange: { - start: toDateString(start), - end: toDateString(end), - }, - eventCount: events.length, - schedule: eventsWithDetails, - }, - message: `Vehicle ${vehicle.name} has ${events.length} scheduled event(s) between ${start.toLocaleDateString()} and ${end.toLocaleDateString()}`, - }; - } - - private async getDriverWorkloadSummary(input: Record): Promise { - const { startDate, endDate } = input; - - const start = new Date(startDate); - const end = new Date(endDate); - - // Get all drivers - const drivers = await this.prisma.driver.findMany({ - where: { deletedAt: null }, - include: { - events: { - where: { - deletedAt: null, - startTime: { gte: start, lte: end }, - status: { not: 'CANCELLED' }, - }, - }, - }, - orderBy: { name: 'asc' }, - }); - - const workloadSummary = drivers.map(driver => { - const events = driver.events; - - // Calculate total hours - const totalMinutes = events.reduce((sum, event) => { - const duration = event.endTime.getTime() - event.startTime.getTime(); - return sum + (duration / 60000); // Convert to minutes - }, 0); - - const totalHours = totalMinutes / 60; - - // Calculate total days in range - const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); - const daysWorked = new Set( - events.map(e => toDateString(e.startTime)) - ).size; - - return { - driverId: driver.id, - driverName: driver.name, - department: driver.department, - isAvailable: driver.isAvailable, - eventCount: events.length, - totalHours: Math.round(totalHours * 10) / 10, - averageHoursPerEvent: events.length > 0 ? Math.round((totalHours / events.length) * 10) / 10 : 0, - daysWorked, - totalDaysInRange: totalDays, - utilizationPercent: Math.round((daysWorked / totalDays) * 100), - }; - }); - - // Sort by event count descending - workloadSummary.sort((a, b) => b.eventCount - a.eventCount); - - return { - success: true, - data: { - dateRange: { - start: toDateString(start), - end: toDateString(end), - }, - totalDrivers: drivers.length, - workload: workloadSummary, - summary: { - totalEvents: workloadSummary.reduce((sum, d) => sum + d.eventCount, 0), - totalHours: Math.round(workloadSummary.reduce((sum, d) => sum + d.totalHours, 0) * 10) / 10, - averageEventsPerDriver: Math.round( - workloadSummary.reduce((sum, d) => sum + d.eventCount, 0) / drivers.length - ), - }, - }, - message: `Workload summary for ${drivers.length} driver(s) from ${start.toLocaleDateString()} to ${end.toLocaleDateString()}`, - }; - } - - private async getCurrentSystemStatus(): Promise { - const now = new Date(); - const today = startOfDay(now); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - const nextWeek = new Date(today); - nextWeek.setDate(nextWeek.getDate() + 7); - - const [ - vipCount, - driverCount, - vehicleCount, - todaysEvents, - upcomingEvents, - unassignedEvents, - availableDrivers, - availableVehicles, - ] = await Promise.all([ - this.prisma.vIP.count({ where: { deletedAt: null } }), - this.prisma.driver.count({ where: { deletedAt: null } }), - this.prisma.vehicle.count({ where: { deletedAt: null } }), - this.prisma.scheduleEvent.count({ - where: { - deletedAt: null, - startTime: { gte: today, lt: tomorrow }, - status: { not: 'CANCELLED' }, - }, - }), - this.prisma.scheduleEvent.count({ - where: { - deletedAt: null, - startTime: { gte: tomorrow, lt: nextWeek }, - status: { not: 'CANCELLED' }, - }, - }), - this.prisma.scheduleEvent.count({ - where: { - deletedAt: null, - startTime: { gte: now }, - status: { in: ['SCHEDULED'] }, - OR: [{ driverId: null }, { vehicleId: null }], - }, - }), - this.prisma.driver.count({ where: { deletedAt: null, isAvailable: true } }), - this.prisma.vehicle.count({ where: { deletedAt: null, status: 'AVAILABLE' } }), - ]); - - const status = { - timestamp: now.toISOString(), - resources: { - vips: vipCount, - drivers: { total: driverCount, available: availableDrivers }, - vehicles: { total: vehicleCount, available: availableVehicles }, - }, - events: { - today: todaysEvents, - next7Days: upcomingEvents, - needingAttention: unassignedEvents, - }, - alerts: [] as string[], - }; - - // Add alerts for issues - if (unassignedEvents > 0) { - status.alerts.push(`${unassignedEvents} upcoming event(s) need driver/vehicle assignment`); - } - if (availableDrivers === 0) { - status.alerts.push('No drivers currently marked as available'); - } - if (availableVehicles === 0) { - status.alerts.push('No vehicles currently available'); - } - - return { - success: true, - data: status, - message: status.alerts.length > 0 - ? `System status retrieved. ATTENTION: ${status.alerts.length} alert(s) require attention.` - : 'System status retrieved. No immediate issues.', - }; - } - } diff --git a/backend/src/drivers/drivers.service.ts b/backend/src/drivers/drivers.service.ts index 795eae3..1a17eb3 100644 --- a/backend/src/drivers/drivers.service.ts +++ b/backend/src/drivers/drivers.service.ts @@ -10,7 +10,6 @@ export class DriversService { private readonly driverInclude = { user: true, events: { - where: { deletedAt: null }, include: { vehicle: true, driver: true }, orderBy: { startTime: 'asc' as const }, }, @@ -29,7 +28,6 @@ export class DriversService { async findAll() { return this.prisma.driver.findMany({ - where: { deletedAt: null }, include: this.driverInclude, orderBy: { name: 'asc' }, }); @@ -37,7 +35,7 @@ export class DriversService { async findOne(id: string) { const driver = await this.prisma.driver.findFirst({ - where: { id, deletedAt: null }, + where: { id }, include: this.driverInclude, }); @@ -50,7 +48,7 @@ export class DriversService { async findByUserId(userId: string) { return this.prisma.driver.findFirst({ - where: { userId, deletedAt: null }, + where: { userId }, include: this.driverInclude, }); } diff --git a/backend/src/drivers/schedule-export.service.ts b/backend/src/drivers/schedule-export.service.ts index 5d081d5..3ce4c6c 100644 --- a/backend/src/drivers/schedule-export.service.ts +++ b/backend/src/drivers/schedule-export.service.ts @@ -45,7 +45,6 @@ export class ScheduleExportService { const events = await this.prisma.scheduleEvent.findMany({ where: { driverId, - deletedAt: null, startTime: { gte: dayStart, lte: endOfDay, @@ -76,7 +75,6 @@ export class ScheduleExportService { const events = await this.prisma.scheduleEvent.findMany({ where: { driverId, - deletedAt: null, endTime: { gte: now, // Include events that haven't ended yet }, @@ -133,7 +131,7 @@ export class ScheduleExportService { */ async generateICS(driverId: string, date: Date, fullSchedule = false): Promise { const driver = await this.prisma.driver.findFirst({ - where: { id: driverId, deletedAt: null }, + where: { id: driverId }, }); if (!driver) { @@ -210,7 +208,7 @@ export class ScheduleExportService { */ async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise { const driver = await this.prisma.driver.findFirst({ - where: { id: driverId, deletedAt: null }, + where: { id: driverId }, }); if (!driver) { @@ -357,7 +355,7 @@ export class ScheduleExportService { fullSchedule = false, ): Promise<{ success: boolean; message: string }> { const driver = await this.prisma.driver.findFirst({ - where: { id: driverId, deletedAt: null }, + where: { id: driverId }, }); if (!driver) { diff --git a/backend/src/events/event-status.service.ts b/backend/src/events/event-status.service.ts index ede025a..d2c32e8 100644 --- a/backend/src/events/event-status.service.ts +++ b/backend/src/events/event-status.service.ts @@ -86,7 +86,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy { startTime: { lte: twentyMinutesFromNow, gt: now }, reminder20MinSent: false, driverId: { not: null }, - deletedAt: null, }, include: { driver: true, @@ -110,7 +109,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy { startTime: { lte: fiveMinutesFromNow, gt: now }, reminder5MinSent: false, driverId: { not: null }, - deletedAt: null, }, include: { driver: true, @@ -218,7 +216,6 @@ Reply: where: { status: EventStatus.SCHEDULED, startTime: { lte: now }, - deletedAt: null, }, include: { driver: true, @@ -264,7 +261,6 @@ Reply: where: { status: EventStatus.IN_PROGRESS, endTime: { lte: gracePeriodAgo }, - deletedAt: null, }, include: { driver: true, @@ -347,7 +343,6 @@ Reply with 1, 2, or 3`; const driver = await this.prisma.driver.findFirst({ where: { phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) }, - deletedAt: null, }, }); @@ -360,7 +355,6 @@ Reply with 1, 2, or 3`; where: { driverId: driver.id, status: EventStatus.IN_PROGRESS, - deletedAt: null, }, include: { vehicle: true }, }); diff --git a/backend/src/events/events.service.ts b/backend/src/events/events.service.ts index dd529b4..314a527 100644 --- a/backend/src/events/events.service.ts +++ b/backend/src/events/events.service.ts @@ -20,7 +20,6 @@ export class EventsService { select: { id: true, title: true, type: true, startTime: true, endTime: true }, }, childEvents: { - where: { deletedAt: null }, select: { id: true, title: true, type: true }, }, } as const; @@ -35,7 +34,6 @@ export class EventsService { const vips = await this.prisma.vIP.findMany({ where: { id: { in: createEventDto.vipIds }, - deletedAt: null, }, }); @@ -90,7 +88,6 @@ export class EventsService { async findAll() { const events = await this.prisma.scheduleEvent.findMany({ - where: { deletedAt: null }, include: this.eventInclude, orderBy: { startTime: 'asc' }, }); @@ -107,7 +104,6 @@ export class EventsService { const vips = await this.prisma.vIP.findMany({ where: { id: { in: Array.from(allVipIds) }, - deletedAt: null, }, }); vips.forEach((vip) => vipsMap.set(vip.id, vip)); @@ -129,7 +125,7 @@ export class EventsService { async findOne(id: string) { const event = await this.prisma.scheduleEvent.findFirst({ - where: { id, deletedAt: null }, + where: { id }, include: this.eventInclude, }); @@ -148,7 +144,6 @@ export class EventsService { const vips = await this.prisma.vIP.findMany({ where: { id: { in: updateEventDto.vipIds }, - deletedAt: null, }, }); @@ -264,7 +259,7 @@ export class EventsService { */ private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) { const vehicle = await this.prisma.vehicle.findFirst({ - where: { id: vehicleId, deletedAt: null }, + where: { id: vehicleId }, }); if (!vehicle) { @@ -272,7 +267,7 @@ export class EventsService { } const vips = await this.prisma.vIP.findMany({ - where: { id: { in: vipIds }, deletedAt: null }, + where: { id: { in: vipIds } }, select: { partySize: true }, }); const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0); @@ -302,7 +297,6 @@ export class EventsService { return this.prisma.scheduleEvent.findMany({ where: { driverId, - deletedAt: null, id: excludeEventId ? { not: excludeEventId } : undefined, OR: [ { @@ -343,7 +337,6 @@ export class EventsService { const vips = await this.prisma.vIP.findMany({ where: { id: { in: event.vipIds }, - deletedAt: null, }, }); diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index f20fb2a..c7ef98b 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -375,9 +375,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} const devices = await this.prisma.gpsDevice.findMany({ where: { isActive: true, - driver: { - deletedAt: null, - }, }, include: { driver: { @@ -743,7 +740,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} where: { role: 'ADMINISTRATOR', isApproved: true, - deletedAt: null, }, }); diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts index f846734..1e1b710 100644 --- a/backend/src/prisma/prisma.service.ts +++ b/backend/src/prisma/prisma.service.ts @@ -1,6 +1,9 @@ import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +// Models that have soft delete (deletedAt field) +const SOFT_DELETE_MODELS = ['User', 'VIP', 'Driver', 'ScheduleEvent', 'Vehicle']; + @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PrismaService.name); @@ -9,18 +12,69 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul super({ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], }); + + // Apply soft-delete middleware + this.applySoftDeleteMiddleware(); } async onModuleInit() { try { await this.$connect(); this.logger.log('✅ Database connected successfully'); + this.logger.log('✅ Soft-delete middleware active for: ' + SOFT_DELETE_MODELS.join(', ')); } catch (error) { this.logger.error('❌ Database connection failed', error); throw error; } } + /** + * Apply Prisma middleware to automatically filter out soft-deleted records + * + * This middleware automatically adds `deletedAt: null` to where clauses for models + * that have a deletedAt field, preventing soft-deleted records from being returned. + * + * Escape hatches: + * - Pass `{ deletedAt: { not: null } }` to query ONLY deleted records + * - Pass `{ deletedAt: undefined }` or any explicit deletedAt filter to bypass middleware + * - Hard delete operations (delete, deleteMany) are not affected + */ + private applySoftDeleteMiddleware() { + this.$use(async (params, next) => { + // Only apply to models with soft delete + if (!SOFT_DELETE_MODELS.includes(params.model || '')) { + return next(params); + } + + // Operations to apply soft-delete filter to + const operations = ['findUnique', 'findFirst', 'findMany', 'count', 'aggregate']; + + if (operations.includes(params.action)) { + // Initialize where clause if it doesn't exist + params.args.where = params.args.where || {}; + + // Only apply filter if deletedAt is not already specified + // This allows explicit queries for deleted records: { deletedAt: { not: null } } + // or to bypass middleware: { deletedAt: undefined } + if (!('deletedAt' in params.args.where)) { + params.args.where.deletedAt = null; + } + } + + // For update/updateMany, ensure we don't accidentally update soft-deleted records + if (params.action === 'update' || params.action === 'updateMany') { + params.args.where = params.args.where || {}; + + // Only apply if not explicitly specified + if (!('deletedAt' in params.args.where)) { + params.args.where.deletedAt = null; + } + } + + return next(params); + }); + } + async onModuleDestroy() { await this.$disconnect(); this.logger.log('Database disconnected'); diff --git a/backend/src/signal/messages.service.ts b/backend/src/signal/messages.service.ts index 6ddab01..a3a5269 100644 --- a/backend/src/signal/messages.service.ts +++ b/backend/src/signal/messages.service.ts @@ -36,7 +36,7 @@ export class MessagesService { */ async getMessagesForDriver(driverId: string, limit: number = 50) { const driver = await this.prisma.driver.findFirst({ - where: { id: driverId, deletedAt: null }, + where: { id: driverId }, }); if (!driver) { @@ -55,7 +55,7 @@ export class MessagesService { */ async sendMessage(dto: SendMessageDto) { const driver = await this.prisma.driver.findFirst({ - where: { id: dto.driverId, deletedAt: null }, + where: { id: dto.driverId }, }); if (!driver) { @@ -113,7 +113,6 @@ export class MessagesService { // Find driver by phone number const driver = await this.prisma.driver.findFirst({ where: { - deletedAt: null, OR: [ { phone: fromNumber }, { phone: normalizedPhone }, @@ -172,7 +171,6 @@ export class MessagesService { where: { driverId: driver.id, status: EventStatus.IN_PROGRESS, - deletedAt: null, }, include: { vehicle: true }, }); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 38c6417..66372a2 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -11,7 +11,6 @@ export class UsersService { async findAll() { return this.prisma.user.findMany({ - where: { deletedAt: null }, include: { driver: true }, orderBy: { createdAt: 'desc' }, }); @@ -19,7 +18,7 @@ export class UsersService { async findOne(id: string) { const user = await this.prisma.user.findFirst({ - where: { id, deletedAt: null }, + where: { id }, include: { driver: true }, }); @@ -136,7 +135,6 @@ export class UsersService { async getPendingUsers() { return this.prisma.user.findMany({ where: { - deletedAt: null, isApproved: false, }, orderBy: { createdAt: 'asc' }, diff --git a/backend/src/vehicles/vehicles.service.ts b/backend/src/vehicles/vehicles.service.ts index 8d7c929..3f15f78 100644 --- a/backend/src/vehicles/vehicles.service.ts +++ b/backend/src/vehicles/vehicles.service.ts @@ -10,7 +10,6 @@ export class VehiclesService { private readonly vehicleInclude = { currentDriver: true, events: { - where: { deletedAt: null }, include: { driver: true, vehicle: true }, orderBy: { startTime: 'asc' as const }, }, @@ -29,7 +28,6 @@ export class VehiclesService { async findAll() { return this.prisma.vehicle.findMany({ - where: { deletedAt: null }, include: this.vehicleInclude, orderBy: { name: 'asc' }, }); @@ -38,7 +36,6 @@ export class VehiclesService { async findAvailable() { return this.prisma.vehicle.findMany({ where: { - deletedAt: null, status: 'AVAILABLE', }, include: { @@ -50,7 +47,7 @@ export class VehiclesService { async findOne(id: string) { const vehicle = await this.prisma.vehicle.findFirst({ - where: { id, deletedAt: null }, + where: { id }, include: this.vehicleInclude, }); @@ -98,12 +95,10 @@ export class VehiclesService { // 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 }, diff --git a/backend/src/vips/vips.service.ts b/backend/src/vips/vips.service.ts index 363607a..6d57ba6 100644 --- a/backend/src/vips/vips.service.ts +++ b/backend/src/vips/vips.service.ts @@ -22,7 +22,6 @@ export class VipsService { async findAll() { return this.prisma.vIP.findMany({ - where: { deletedAt: null }, include: { flights: true, }, @@ -32,7 +31,7 @@ export class VipsService { async findOne(id: string) { const vip = await this.prisma.vIP.findFirst({ - where: { id, deletedAt: null }, + where: { id }, include: { flights: true, }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bcd6b4a..243aca2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import { Callback } from '@/pages/Callback'; import { PendingApproval } from '@/pages/PendingApproval'; import { Dashboard } from '@/pages/Dashboard'; import { CommandCenter } from '@/pages/CommandCenter'; -import { VIPList } from '@/pages/VipList'; +import { VIPList } from '@/pages/VIPList'; import { VIPSchedule } from '@/pages/VIPSchedule'; import { FleetPage } from '@/pages/FleetPage'; import { EventList } from '@/pages/EventList'; diff --git a/frontend/src/components/SortableHeader.tsx b/frontend/src/components/SortableHeader.tsx new file mode 100644 index 0000000..1ef167b --- /dev/null +++ b/frontend/src/components/SortableHeader.tsx @@ -0,0 +1,36 @@ +import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; + +interface SortableHeaderProps { + column: T; + label: string; + currentSort: { + key: string; + direction: 'asc' | 'desc'; + }; + onSort: (key: T) => void; + className?: string; +} + +export function SortableHeader({ column, label, currentSort, onSort, className = '' }: SortableHeaderProps) { + const isActive = currentSort.key === column; + + return ( + onSort(column)} + > +
+ {label} + {isActive ? ( + currentSort.direction === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} +
+ + ); +} diff --git a/frontend/src/hooks/useListPage.ts b/frontend/src/hooks/useListPage.ts new file mode 100644 index 0000000..550bc5a --- /dev/null +++ b/frontend/src/hooks/useListPage.ts @@ -0,0 +1,55 @@ +import { useState, useMemo } from 'react'; +import { useDebounce } from './useDebounce'; + +interface UseListPageOptions { + defaultSortKey: T; + defaultSortDirection?: 'asc' | 'desc'; +} + +export function useListPage(options: UseListPageOptions) { + const { defaultSortKey, defaultSortDirection = 'asc' } = options; + + // Search state + const [search, setSearch] = useState(''); + const debouncedSearch = useDebounce(search, 300); + + // Sort state + const [sortKey, setSortKey] = useState(defaultSortKey); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(defaultSortDirection); + + // Generic filter state (key-value pairs) + const [filters, setFiltersState] = useState>({}); + + const handleSort = (key: T) => { + if (sortKey === key) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('asc'); + } + }; + + const setFilter = (key: string, value: any) => { + setFiltersState((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const clearFilters = () => { + setSearch(''); + setFiltersState({}); + }; + + return { + search, + setSearch, + debouncedSearch, + sortKey, + sortDirection, + handleSort, + filters, + setFilter, + clearFilters, + }; +} diff --git a/frontend/src/pages/DriverList.tsx b/frontend/src/pages/DriverList.tsx index 9deb09d..f7bcbad 100644 --- a/frontend/src/pages/DriverList.tsx +++ b/frontend/src/pages/DriverList.tsx @@ -3,13 +3,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { api } from '@/lib/api'; import { Driver } from '@/types'; -import { Plus, Edit, Trash2, Search, X, Filter, ArrowUpDown, Send, Eye } from 'lucide-react'; +import { Plus, Edit, Trash2, Search, X, Filter, Send, Eye } from 'lucide-react'; import { DriverForm, DriverFormData } from '@/components/DriverForm'; import { TableSkeleton, CardSkeleton } from '@/components/Skeleton'; import { FilterModal } from '@/components/FilterModal'; import { FilterChip } from '@/components/FilterChip'; import { ConfirmModal } from '@/components/ConfirmModal'; -import { useDebounce } from '@/hooks/useDebounce'; +import { SortableHeader } from '@/components/SortableHeader'; +import { useListPage } from '@/hooks/useListPage'; import { DriverChatBubble } from '@/components/DriverChatBubble'; import { DriverChatModal } from '@/components/DriverChatModal'; import { DriverScheduleModal } from '@/components/DriverScheduleModal'; @@ -22,8 +23,19 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { const [editingDriver, setEditingDriver] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - // Search and filter state - const [searchTerm, setSearchTerm] = useState(''); + // List page state management + const { + search: searchTerm, + setSearch: setSearchTerm, + debouncedSearch: debouncedSearchTerm, + sortKey: sortColumn, + sortDirection, + handleSort, + } = useListPage<'name' | 'phone' | 'department'>({ + defaultSortKey: 'name', + }); + + // Filter state const [selectedDepartments, setSelectedDepartments] = useState([]); const [filterModalOpen, setFilterModalOpen] = useState(false); @@ -36,13 +48,6 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { // Confirm delete modal state const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); - // Sort state - const [sortColumn, setSortColumn] = useState<'name' | 'phone' | 'department'>('name'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - - // Debounce search term - const debouncedSearchTerm = useDebounce(searchTerm, 300); - // Fetch unread message counts const { data: unreadCounts } = useUnreadCounts(); @@ -184,15 +189,6 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { setSelectedDepartments([]); }; - const handleSort = (column: typeof sortColumn) => { - if (sortColumn === column) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn(column); - setSortDirection('asc'); - } - }; - const handleRemoveDepartmentFilter = (dept: string) => { setSelectedDepartments((prev) => prev.filter((d) => d !== dept)); }; @@ -359,36 +355,24 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { - - - + + + diff --git a/frontend/src/pages/EventList.tsx b/frontend/src/pages/EventList.tsx index 00c1a9c..d895c4e 100644 --- a/frontend/src/pages/EventList.tsx +++ b/frontend/src/pages/EventList.tsx @@ -4,11 +4,12 @@ import { useLocation, useNavigate } from 'react-router-dom'; import toast from 'react-hot-toast'; import { api } from '@/lib/api'; import { ScheduleEvent, EventType } from '@/types'; -import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; +import { Plus, Edit, Trash2, Search } from 'lucide-react'; import { EventForm, EventFormData } from '@/components/EventForm'; import { Loading } from '@/components/Loading'; import { InlineDriverSelector } from '@/components/InlineDriverSelector'; import { ConfirmModal } from '@/components/ConfirmModal'; +import { SortableHeader } from '@/components/SortableHeader'; import { useFormattedDate } from '@/hooks/useFormattedDate'; import { queryKeys } from '@/lib/query-keys'; @@ -26,12 +27,14 @@ export function EventList() { const [isSubmitting, setIsSubmitting] = useState(false); const [activeFilter, setActiveFilter] = useState('ALL'); const [searchQuery, setSearchQuery] = useState(''); - const [sortField, setSortField] = useState('startTime'); - const [sortDirection, setSortDirection] = useState('asc'); // Confirm delete modal state const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; title: string } | null>(null); + // Sort state (inline instead of useListPage since search is not debounced here) + const [sortField, setSortField] = useState('startTime'); + const [sortDirection, setSortDirection] = useState('asc'); + const { data: events, isLoading } = useQuery({ queryKey: queryKeys.events.all, queryFn: async () => { @@ -322,77 +325,47 @@ export function EventList() {
handleSort('name')} - > -
- Name - - {sortColumn === 'name' && {sortDirection === 'asc' ? '↑' : '↓'}} -
-
handleSort('phone')} - > -
- Phone - - {sortColumn === 'phone' && {sortDirection === 'asc' ? '↑' : '↓'}} -
-
handleSort('department')} - > -
- Department - - {sortColumn === 'department' && {sortDirection === 'asc' ? '↑' : '↓'}} -
-
Assigned Events
- - - + + + - - + + diff --git a/frontend/src/pages/VipList.tsx b/frontend/src/pages/VipList.tsx index 0cd6af3..13a57b4 100644 --- a/frontend/src/pages/VipList.tsx +++ b/frontend/src/pages/VipList.tsx @@ -4,13 +4,14 @@ import { useNavigate } from 'react-router-dom'; import toast from 'react-hot-toast'; import { api } from '@/lib/api'; import { VIP } from '@/types'; -import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ArrowUpDown, ClipboardList } from 'lucide-react'; +import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ClipboardList } from 'lucide-react'; import { VIPForm, VIPFormData } from '@/components/VIPForm'; import { VIPCardSkeleton, TableSkeleton } from '@/components/Skeleton'; import { FilterModal } from '@/components/FilterModal'; import { FilterChip } from '@/components/FilterChip'; import { ConfirmModal } from '@/components/ConfirmModal'; -import { useDebounce } from '@/hooks/useDebounce'; +import { SortableHeader } from '@/components/SortableHeader'; +import { useListPage } from '@/hooks/useListPage'; import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels'; export function VIPList() { @@ -20,25 +21,29 @@ export function VIPList() { const [editingVIP, setEditingVIP] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - // Search and filter state - const [searchTerm, setSearchTerm] = useState(''); + // List page state management + const { + search: searchTerm, + setSearch: setSearchTerm, + debouncedSearch: debouncedSearchTerm, + sortKey: sortColumn, + sortDirection, + handleSort, + } = useListPage<'name' | 'organization' | 'department' | 'arrivalMode'>({ + defaultSortKey: 'name', + }); + + // Filter state const [selectedDepartments, setSelectedDepartments] = useState([]); const [selectedArrivalModes, setSelectedArrivalModes] = useState([]); const [filterModalOpen, setFilterModalOpen] = useState(false); - // Sort state - const [sortColumn, setSortColumn] = useState<'name' | 'organization' | 'department' | 'arrivalMode'>('name'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - // Roster-only toggle (hidden by default) const [showRosterOnly, setShowRosterOnly] = useState(false); // Confirm delete modal state const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); - // Debounce search term for better performance - const debouncedSearchTerm = useDebounce(searchTerm, 300); - const { data: vips, isLoading } = useQuery({ queryKey: ['vips'], queryFn: async () => { @@ -175,15 +180,6 @@ export function VIPList() { setSelectedArrivalModes([]); }; - const handleSort = (column: typeof sortColumn) => { - if (sortColumn === column) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn(column); - setSortDirection('asc'); - } - }; - const handleRemoveDepartmentFilter = (dept: string) => { setSelectedDepartments((prev) => prev.filter((d) => d !== dept)); }; @@ -363,46 +359,30 @@ export function VIPList() {
handleSort('title')} - > -
- Title - {sortField === 'title' ? ( - sortDirection === 'asc' ? : - ) : ( - - )} -
-
handleSort('type')} - > -
- Type - {sortField === 'type' ? ( - sortDirection === 'asc' ? : - ) : ( - - )} -
-
handleSort('vips')} - > -
- VIPs - {sortField === 'vips' ? ( - sortDirection === 'asc' ? : - ) : ( - - )} -
-
Vehicle Driver handleSort('startTime')} - > -
- Start Time - {sortField === 'startTime' ? ( - sortDirection === 'asc' ? : - ) : ( - - )} -
-
handleSort('status')} - > -
- Status - {sortField === 'status' ? ( - sortDirection === 'asc' ? : - ) : ( - - )} -
-
Actions
- - - - + + + +
handleSort('name')} - > -
- Name - - {sortColumn === 'name' && {sortDirection === 'asc' ? '↑' : '↓'}} -
-
handleSort('organization')} - > -
- Organization - - {sortColumn === 'organization' && {sortDirection === 'asc' ? '↑' : '↓'}} -
-
handleSort('department')} - > -
- Department - - {sortColumn === 'department' && {sortDirection === 'asc' ? '↑' : '↓'}} -
-
handleSort('arrivalMode')} - > -
- Arrival Mode - - {sortColumn === 'arrivalMode' && {sortDirection === 'asc' ? '↑' : '↓'}} -
-
Actions