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 <noreply@anthropic.com>
463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
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<string, any>): Promise<ToolResult> {
|
|
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<ToolResult> {
|
|
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<string, any>): Promise<ToolResult> {
|
|
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<string, any>): Promise<ToolResult> {
|
|
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<string, any>): Promise<ToolResult> {
|
|
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<ToolResult> {
|
|
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<string, any>): Promise<ToolResult> {
|
|
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<string, any>): Promise<ToolResult> {
|
|
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<string, any>): Promise<ToolResult> {
|
|
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.`,
|
|
};
|
|
}
|
|
}
|