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>
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
@@ -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<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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
304
backend/src/copilot/copilot-reports.service.ts
Normal file
304
backend/src/copilot/copilot-reports.service.ts
Normal file
@@ -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<string, any>): Promise<ToolResult> {
|
||||
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<string, number>,
|
||||
);
|
||||
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<typeof events[0]['driver']>[]);
|
||||
|
||||
// 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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
1282
backend/src/copilot/copilot-schedule.service.ts
Normal file
1282
backend/src/copilot/copilot-schedule.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
275
backend/src/copilot/copilot-vip.service.ts
Normal file
275
backend/src/copilot/copilot-vip.service.ts
Normal file
@@ -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<string, any>): Promise<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<string, any>): Promise<ToolResult> {
|
||||
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<string, any>): Promise<ToolResult> {
|
||||
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<string, any>): Promise<ToolResult> {
|
||||
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<ToolResult> {
|
||||
const flights = await this.prisma.flight.findMany({
|
||||
where: { vipId },
|
||||
orderBy: { flightDate: 'asc' },
|
||||
});
|
||||
|
||||
return { success: true, data: flights };
|
||||
}
|
||||
|
||||
async createFlight(input: Record<string, any>): Promise<ToolResult> {
|
||||
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<string, any>): Promise<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<Buffer> {
|
||||
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) {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user