Initial commit - Current state of vip-coordinator

This commit is contained in:
2026-01-24 09:30:26 +01:00
commit aa900505b9
96 changed files with 31868 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
import fs from 'fs';
import path from 'path';
interface DataStore {
vips: any[];
drivers: any[];
schedules: { [vipId: string]: any[] };
adminSettings: any;
users: any[];
}
class DataService {
private dataDir: string;
private dataFile: string;
private data: DataStore;
constructor() {
this.dataDir = path.join(process.cwd(), 'data');
this.dataFile = path.join(this.dataDir, 'vip-coordinator.json');
// Ensure data directory exists
if (!fs.existsSync(this.dataDir)) {
fs.mkdirSync(this.dataDir, { recursive: true });
}
this.data = this.loadData();
}
private loadData(): DataStore {
try {
if (fs.existsSync(this.dataFile)) {
const fileContent = fs.readFileSync(this.dataFile, 'utf8');
const loadedData = JSON.parse(fileContent);
console.log(`✅ Loaded data from ${this.dataFile}`);
console.log(` - VIPs: ${loadedData.vips?.length || 0}`);
console.log(` - Drivers: ${loadedData.drivers?.length || 0}`);
console.log(` - Users: ${loadedData.users?.length || 0}`);
console.log(` - Schedules: ${Object.keys(loadedData.schedules || {}).length} VIPs with schedules`);
// Ensure users array exists for backward compatibility
if (!loadedData.users) {
loadedData.users = [];
}
return loadedData;
}
} catch (error) {
console.error('Error loading data file:', error);
}
// Return default empty data structure
console.log('📝 Starting with empty data store');
return {
vips: [],
drivers: [],
schedules: {},
users: [],
adminSettings: {
apiKeys: {
aviationStackKey: process.env.AVIATIONSTACK_API_KEY || '',
googleMapsKey: '',
twilioKey: ''
},
systemSettings: {
defaultPickupLocation: '',
defaultDropoffLocation: '',
timeZone: 'America/New_York',
notificationsEnabled: false
}
}
};
}
private saveData(): void {
try {
const dataToSave = JSON.stringify(this.data, null, 2);
fs.writeFileSync(this.dataFile, dataToSave, 'utf8');
console.log(`💾 Data saved to ${this.dataFile}`);
} catch (error) {
console.error('Error saving data file:', error);
}
}
// VIP operations
getVips(): any[] {
return this.data.vips;
}
addVip(vip: any): any {
this.data.vips.push(vip);
this.saveData();
return vip;
}
updateVip(id: string, updatedVip: any): any | null {
const index = this.data.vips.findIndex(vip => vip.id === id);
if (index !== -1) {
this.data.vips[index] = updatedVip;
this.saveData();
return this.data.vips[index];
}
return null;
}
deleteVip(id: string): any | null {
const index = this.data.vips.findIndex(vip => vip.id === id);
if (index !== -1) {
const deletedVip = this.data.vips.splice(index, 1)[0];
// Also delete the VIP's schedule
delete this.data.schedules[id];
this.saveData();
return deletedVip;
}
return null;
}
// Driver operations
getDrivers(): any[] {
return this.data.drivers;
}
addDriver(driver: any): any {
this.data.drivers.push(driver);
this.saveData();
return driver;
}
updateDriver(id: string, updatedDriver: any): any | null {
const index = this.data.drivers.findIndex(driver => driver.id === id);
if (index !== -1) {
this.data.drivers[index] = updatedDriver;
this.saveData();
return this.data.drivers[index];
}
return null;
}
deleteDriver(id: string): any | null {
const index = this.data.drivers.findIndex(driver => driver.id === id);
if (index !== -1) {
const deletedDriver = this.data.drivers.splice(index, 1)[0];
this.saveData();
return deletedDriver;
}
return null;
}
// Schedule operations
getSchedule(vipId: string): any[] {
return this.data.schedules[vipId] || [];
}
addScheduleEvent(vipId: string, event: any): any {
if (!this.data.schedules[vipId]) {
this.data.schedules[vipId] = [];
}
this.data.schedules[vipId].push(event);
this.saveData();
return event;
}
updateScheduleEvent(vipId: string, eventId: string, updatedEvent: any): any | null {
if (!this.data.schedules[vipId]) {
return null;
}
const index = this.data.schedules[vipId].findIndex(event => event.id === eventId);
if (index !== -1) {
this.data.schedules[vipId][index] = updatedEvent;
this.saveData();
return this.data.schedules[vipId][index];
}
return null;
}
deleteScheduleEvent(vipId: string, eventId: string): any | null {
if (!this.data.schedules[vipId]) {
return null;
}
const index = this.data.schedules[vipId].findIndex(event => event.id === eventId);
if (index !== -1) {
const deletedEvent = this.data.schedules[vipId].splice(index, 1)[0];
this.saveData();
return deletedEvent;
}
return null;
}
getAllSchedules(): { [vipId: string]: any[] } {
return this.data.schedules;
}
// Admin settings operations
getAdminSettings(): any {
return this.data.adminSettings;
}
updateAdminSettings(settings: any): void {
this.data.adminSettings = { ...this.data.adminSettings, ...settings };
this.saveData();
}
// Backup and restore operations
createBackup(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupFile = path.join(this.dataDir, `backup-${timestamp}.json`);
try {
fs.copyFileSync(this.dataFile, backupFile);
console.log(`📦 Backup created: ${backupFile}`);
return backupFile;
} catch (error) {
console.error('Error creating backup:', error);
throw error;
}
}
// User operations
getUsers(): any[] {
return this.data.users;
}
getUserByEmail(email: string): any | null {
return this.data.users.find(user => user.email === email) || null;
}
getUserById(id: string): any | null {
return this.data.users.find(user => user.id === id) || null;
}
addUser(user: any): any {
// Add timestamps
const userWithTimestamps = {
...user,
created_at: new Date().toISOString(),
last_sign_in_at: new Date().toISOString()
};
this.data.users.push(userWithTimestamps);
this.saveData();
console.log(`👤 Added user: ${user.name} (${user.email}) as ${user.role}`);
return userWithTimestamps;
}
updateUser(email: string, updatedUser: any): any | null {
const index = this.data.users.findIndex(user => user.email === email);
if (index !== -1) {
this.data.users[index] = { ...this.data.users[index], ...updatedUser };
this.saveData();
console.log(`👤 Updated user: ${this.data.users[index].name} (${email})`);
return this.data.users[index];
}
return null;
}
updateUserRole(email: string, role: string): any | null {
const index = this.data.users.findIndex(user => user.email === email);
if (index !== -1) {
this.data.users[index].role = role;
this.saveData();
console.log(`👤 Updated user role: ${this.data.users[index].name} (${email}) -> ${role}`);
return this.data.users[index];
}
return null;
}
updateUserLastSignIn(email: string): any | null {
const index = this.data.users.findIndex(user => user.email === email);
if (index !== -1) {
this.data.users[index].last_sign_in_at = new Date().toISOString();
this.saveData();
return this.data.users[index];
}
return null;
}
deleteUser(email: string): any | null {
const index = this.data.users.findIndex(user => user.email === email);
if (index !== -1) {
const deletedUser = this.data.users.splice(index, 1)[0];
this.saveData();
console.log(`👤 Deleted user: ${deletedUser.name} (${email})`);
return deletedUser;
}
return null;
}
getUserCount(): number {
return this.data.users.length;
}
getDataStats(): any {
return {
vips: this.data.vips.length,
drivers: this.data.drivers.length,
users: this.data.users.length,
scheduledEvents: Object.values(this.data.schedules).reduce((total, events) => total + events.length, 0),
vipsWithSchedules: Object.keys(this.data.schedules).length,
dataFile: this.dataFile,
lastModified: fs.existsSync(this.dataFile) ? fs.statSync(this.dataFile).mtime : null
};
}
}
export default new DataService();

View File

@@ -0,0 +1,514 @@
import { Pool, PoolClient } from 'pg';
import { createClient, RedisClientType } from 'redis';
class DatabaseService {
private pool: Pool;
private redis: RedisClientType;
constructor() {
const useSSL = process.env.DATABASE_SSL === 'true';
this.pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: useSSL ? { rejectUnauthorized: false } : false
});
// Initialize Redis connection
this.redis = createClient({
socket: {
host: process.env.REDIS_HOST || 'redis',
port: parseInt(process.env.REDIS_PORT || '6379')
}
});
this.redis.on('error', (err) => {
console.error('❌ Redis connection error:', err);
});
// Test connections on startup
this.testConnection();
this.testRedisConnection();
}
private async testConnection(): Promise<void> {
try {
const client = await this.pool.connect();
console.log('✅ Connected to PostgreSQL database');
client.release();
} catch (error) {
console.error('❌ Failed to connect to PostgreSQL database:', error);
}
}
private async testRedisConnection(): Promise<void> {
try {
if (!this.redis.isOpen) {
await this.redis.connect();
}
await this.redis.ping();
console.log('✅ Connected to Redis');
} catch (error) {
console.error('❌ Failed to connect to Redis:', error);
}
}
async query(text: string, params?: any[]): Promise<any> {
const client = await this.pool.connect();
try {
const result = await client.query(text, params);
return result;
} finally {
client.release();
}
}
async getClient(): Promise<PoolClient> {
return await this.pool.connect();
}
async close(): Promise<void> {
await this.pool.end();
if (this.redis.isOpen) {
await this.redis.disconnect();
}
}
// Initialize database tables
async initializeTables(): Promise<void> {
try {
// Create users table (matching the actual schema)
await this.query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(255) PRIMARY KEY,
google_id VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL CHECK (role IN ('driver', 'coordinator', 'administrator')),
profile_picture_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
is_active BOOLEAN DEFAULT true,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
approval_status VARCHAR(20) DEFAULT 'pending' CHECK (approval_status IN ('pending', 'approved', 'denied'))
)
`);
// Add approval_status column if it doesn't exist (migration for existing databases)
await this.query(`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS approval_status VARCHAR(20) DEFAULT 'pending' CHECK (approval_status IN ('pending', 'approved', 'denied'))
`);
// Admin settings storage table
await this.query(`
CREATE TABLE IF NOT EXISTS admin_settings (
id SERIAL PRIMARY KEY,
setting_key VARCHAR(255) UNIQUE NOT NULL,
setting_value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await this.query(`
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
`);
await this.query(`
DROP TRIGGER IF EXISTS update_admin_settings_updated_at ON admin_settings
`);
await this.query(`
CREATE TRIGGER update_admin_settings_updated_at
BEFORE UPDATE ON admin_settings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column()
`);
// Create indexes
await this.query(`
CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)
`);
console.log('✅ Database tables initialized successfully');
} catch (error) {
console.error('❌ Failed to initialize database tables:', error);
throw error;
}
}
// User management methods
async createUser(user: {
id: string;
google_id: string;
email: string;
name: string;
profile_picture_url?: string;
role: string;
}): Promise<any> {
const query = `
INSERT INTO users (id, google_id, email, name, profile_picture_url, role, last_login)
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
RETURNING *
`;
const values = [
user.id,
user.google_id,
user.email,
user.name,
user.profile_picture_url || null,
user.role
];
const result = await this.query(query, values);
console.log(`👤 Created user: ${user.name} (${user.email}) as ${user.role}`);
return result.rows[0];
}
async getUserByEmail(email: string): Promise<any> {
const query = 'SELECT * FROM users WHERE email = $1';
const result = await this.query(query, [email]);
return result.rows[0] || null;
}
async getUserById(id: string): Promise<any> {
const query = 'SELECT * FROM users WHERE id = $1';
const result = await this.query(query, [id]);
return result.rows[0] || null;
}
async migrateUserId(oldId: string, newId: string): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(
'UPDATE drivers SET user_id = $2 WHERE user_id = $1',
[oldId, newId]
);
await client.query(
'UPDATE users SET id = $2, google_id = $2 WHERE id = $1',
[oldId, newId]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async getAllUsers(): Promise<any[]> {
const query = 'SELECT * FROM users ORDER BY created_at ASC';
const result = await this.query(query);
return result.rows;
}
async updateUserRole(email: string, role: string): Promise<any> {
const query = `
UPDATE users
SET role = $1, updated_at = CURRENT_TIMESTAMP
WHERE email = $2
RETURNING *
`;
const result = await this.query(query, [role, email]);
if (result.rows[0]) {
console.log(`👤 Updated user role: ${result.rows[0].name} (${email}) -> ${role}`);
}
return result.rows[0] || null;
}
async updateUserLastSignIn(email: string): Promise<any> {
const query = `
UPDATE users
SET last_login = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE email = $1
RETURNING *
`;
const result = await this.query(query, [email]);
return result.rows[0] || null;
}
async deleteUser(email: string): Promise<any> {
const query = 'DELETE FROM users WHERE email = $1 RETURNING *';
const result = await this.query(query, [email]);
if (result.rows[0]) {
console.log(`👤 Deleted user: ${result.rows[0].name} (${email})`);
}
return result.rows[0] || null;
}
async getUserCount(): Promise<number> {
const query = 'SELECT COUNT(*) as count FROM users';
const result = await this.query(query);
return parseInt(result.rows[0].count);
}
// User approval methods
async updateUserApprovalStatus(email: string, status: 'pending' | 'approved' | 'denied'): Promise<any> {
const query = `
UPDATE users
SET approval_status = $1, updated_at = CURRENT_TIMESTAMP
WHERE email = $2
RETURNING *
`;
const result = await this.query(query, [status, email]);
if (result.rows[0]) {
console.log(`👤 Updated user approval: ${result.rows[0].name} (${email}) -> ${status}`);
}
return result.rows[0] || null;
}
async getPendingUsers(): Promise<any[]> {
const query = 'SELECT * FROM users WHERE approval_status = $1 ORDER BY created_at ASC';
const result = await this.query(query, ['pending']);
return result.rows;
}
async getApprovedUserCount(): Promise<number> {
const query = 'SELECT COUNT(*) as count FROM users WHERE approval_status = $1';
const result = await this.query(query, ['approved']);
return parseInt(result.rows[0].count);
}
// Initialize all database tables and schema
async initializeDatabase(): Promise<void> {
try {
await this.initializeTables();
await this.initializeVipTables();
// Approve all existing users (migration for approval system)
await this.query(`
UPDATE users
SET approval_status = 'approved'
WHERE approval_status IS NULL OR approval_status = 'pending'
`);
console.log('✅ Approved all existing users');
console.log('✅ Database schema initialization completed');
} catch (error) {
console.error('❌ Failed to initialize database schema:', error);
throw error;
}
}
// VIP schema (flights, drivers, schedules)
async initializeVipTables(): Promise<void> {
try {
await this.query(`
CREATE TABLE IF NOT EXISTS vips (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await this.query(`
ALTER TABLE vips
ADD COLUMN IF NOT EXISTS organization VARCHAR(255),
ADD COLUMN IF NOT EXISTS department VARCHAR(255) DEFAULT 'Office of Development',
ADD COLUMN IF NOT EXISTS transport_mode VARCHAR(50),
ADD COLUMN IF NOT EXISTS expected_arrival TIMESTAMP,
ADD COLUMN IF NOT EXISTS needs_airport_pickup BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS needs_venue_transport BOOLEAN DEFAULT true
`);
await this.query(`
CREATE TABLE IF NOT EXISTS flights (
id SERIAL PRIMARY KEY,
vip_id VARCHAR(255) REFERENCES vips(id) ON DELETE CASCADE,
flight_number VARCHAR(50) NOT NULL,
flight_date DATE NOT NULL,
segment INTEGER NOT NULL,
departure_airport VARCHAR(10),
arrival_airport VARCHAR(10),
scheduled_departure TIMESTAMP,
scheduled_arrival TIMESTAMP,
actual_departure TIMESTAMP,
actual_arrival TIMESTAMP,
status VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await this.query(`
CREATE TABLE IF NOT EXISTS drivers (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await this.query(`
ALTER TABLE drivers
ADD COLUMN IF NOT EXISTS phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS department VARCHAR(255) DEFAULT 'Office of Development',
ADD COLUMN IF NOT EXISTS user_id VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL
`);
await this.query(`
CREATE TABLE IF NOT EXISTS schedule_events (
id VARCHAR(255) PRIMARY KEY,
vip_id VARCHAR(255) REFERENCES vips(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
location VARCHAR(255) NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
description TEXT,
assigned_driver_id VARCHAR(255) REFERENCES drivers(id) ON DELETE SET NULL,
status VARCHAR(50) DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'in-progress', 'completed', 'cancelled')),
event_type VARCHAR(50) NOT NULL CHECK (event_type IN ('transport', 'meeting', 'event', 'meal', 'accommodation')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await this.query(`
DROP TABLE IF EXISTS schedules
`);
await this.query(`
ALTER TABLE schedule_events
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS assigned_driver_id VARCHAR(255) REFERENCES drivers(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'scheduled',
ADD COLUMN IF NOT EXISTS event_type VARCHAR(50),
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_vips_transport_mode ON vips(transport_mode)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_flights_vip_id ON flights(vip_id)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_flights_date ON flights(flight_date)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_schedule_events_vip_id ON schedule_events(vip_id)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_schedule_events_driver_id ON schedule_events(assigned_driver_id)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_schedule_events_start_time ON schedule_events(start_time)
`);
console.log('✅ VIP and schedule tables initialized successfully');
} catch (error) {
console.error('❌ Failed to initialize VIP tables:', error);
}
}
// Redis-based driver location tracking
async getDriverLocation(driverId: string): Promise<{ lat: number; lng: number } | null> {
try {
if (!this.redis.isOpen) {
await this.redis.connect();
}
const location = await this.redis.hGetAll(`driver:${driverId}:location`);
if (location && location.lat && location.lng) {
return {
lat: parseFloat(location.lat),
lng: parseFloat(location.lng)
};
}
return null;
} catch (error) {
console.error('❌ Error getting driver location from Redis:', error);
return null;
}
}
async updateDriverLocation(driverId: string, location: { lat: number; lng: number }): Promise<void> {
try {
if (!this.redis.isOpen) {
await this.redis.connect();
}
const key = `driver:${driverId}:location`;
await this.redis.hSet(key, {
lat: location.lat.toString(),
lng: location.lng.toString(),
updated_at: new Date().toISOString()
});
// Set expiration to 24 hours
await this.redis.expire(key, 24 * 60 * 60);
} catch (error) {
console.error('❌ Error updating driver location in Redis:', error);
}
}
async getAllDriverLocations(): Promise<{ [driverId: string]: { lat: number; lng: number } }> {
try {
if (!this.redis.isOpen) {
await this.redis.connect();
}
const keys = await this.redis.keys('driver:*:location');
const locations: { [driverId: string]: { lat: number; lng: number } } = {};
for (const key of keys) {
const driverId = key.split(':')[1];
const location = await this.redis.hGetAll(key);
if (location && location.lat && location.lng) {
locations[driverId] = {
lat: parseFloat(location.lat),
lng: parseFloat(location.lng)
};
}
}
return locations;
} catch (error) {
console.error('❌ Error getting all driver locations from Redis:', error);
return {};
}
}
async removeDriverLocation(driverId: string): Promise<void> {
try {
if (!this.redis.isOpen) {
await this.redis.connect();
}
await this.redis.del(`driver:${driverId}:location`);
} catch (error) {
console.error('❌ Error removing driver location from Redis:', error);
}
}
}
export default new DatabaseService();

View File

@@ -0,0 +1,184 @@
interface ScheduleEvent {
id: string;
title: string;
location: string;
startTime: string;
endTime: string;
assignedDriverId?: string;
vipId: string;
vipName: string;
}
interface ConflictInfo {
type: 'overlap' | 'tight_turnaround' | 'back_to_back';
severity: 'low' | 'medium' | 'high';
message: string;
conflictingEvent: ScheduleEvent;
timeDifference?: number; // minutes
}
interface DriverAvailability {
driverId: string;
driverName: string;
status: 'available' | 'scheduled' | 'overlapping' | 'tight_turnaround';
assignmentCount: number;
conflicts: ConflictInfo[];
currentAssignments: ScheduleEvent[];
}
class DriverConflictService {
// Check for conflicts when assigning a driver to an event
checkDriverConflicts(
driverId: string,
newEvent: { startTime: string; endTime: string; location: string },
allSchedules: { [vipId: string]: ScheduleEvent[] },
drivers: any[]
): ConflictInfo[] {
const conflicts: ConflictInfo[] = [];
const driver = drivers.find(d => d.id === driverId);
if (!driver) return conflicts;
// Get all events assigned to this driver
const driverEvents = this.getDriverEvents(driverId, allSchedules);
const newStartTime = new Date(newEvent.startTime);
const newEndTime = new Date(newEvent.endTime);
for (const existingEvent of driverEvents) {
const existingStart = new Date(existingEvent.startTime);
const existingEnd = new Date(existingEvent.endTime);
// Check for direct time overlap
if (this.hasTimeOverlap(newStartTime, newEndTime, existingStart, existingEnd)) {
conflicts.push({
type: 'overlap',
severity: 'high',
message: `Direct time conflict with "${existingEvent.title}" for ${existingEvent.vipName}`,
conflictingEvent: existingEvent
});
}
// Check for tight turnaround (less than 15 minutes between events)
else {
const timeBetween = this.getTimeBetweenEvents(
newStartTime, newEndTime, existingStart, existingEnd
);
if (timeBetween !== null && timeBetween < 15) {
conflicts.push({
type: 'tight_turnaround',
severity: timeBetween < 5 ? 'high' : 'medium',
message: `Only ${timeBetween} minutes between events. Previous: "${existingEvent.title}"`,
conflictingEvent: existingEvent,
timeDifference: timeBetween
});
}
}
}
return conflicts;
}
// Get availability status for all drivers for a specific time slot
getDriverAvailability(
eventTime: { startTime: string; endTime: string; location: string },
allSchedules: { [vipId: string]: ScheduleEvent[] },
drivers: any[]
): DriverAvailability[] {
return drivers.map(driver => {
const conflicts = this.checkDriverConflicts(driver.id, eventTime, allSchedules, drivers);
const driverEvents = this.getDriverEvents(driver.id, allSchedules);
let status: DriverAvailability['status'] = 'available';
if (conflicts.length > 0) {
const hasOverlap = conflicts.some(c => c.type === 'overlap');
const hasTightTurnaround = conflicts.some(c => c.type === 'tight_turnaround');
if (hasOverlap) {
status = 'overlapping';
} else if (hasTightTurnaround) {
status = 'tight_turnaround';
}
} else if (driverEvents.length > 0) {
status = 'scheduled';
}
return {
driverId: driver.id,
driverName: driver.name,
status,
assignmentCount: driverEvents.length,
conflicts,
currentAssignments: driverEvents
};
});
}
// Get all events assigned to a specific driver
private getDriverEvents(driverId: string, allSchedules: { [vipId: string]: ScheduleEvent[] }): ScheduleEvent[] {
const driverEvents: ScheduleEvent[] = [];
Object.entries(allSchedules).forEach(([vipId, events]) => {
events.forEach(event => {
if (event.assignedDriverId === driverId) {
driverEvents.push({
...event,
vipId,
vipName: event.title // We'll need to get actual VIP name from VIP data
});
}
});
});
// Sort by start time
return driverEvents.sort((a, b) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
}
// Check if two time periods overlap
private hasTimeOverlap(
start1: Date, end1: Date,
start2: Date, end2: Date
): boolean {
return start1 < end2 && start2 < end1;
}
// Get minutes between two events (null if they overlap)
private getTimeBetweenEvents(
newStart: Date, newEnd: Date,
existingStart: Date, existingEnd: Date
): number | null {
// If new event is after existing event
if (newStart >= existingEnd) {
return Math.floor((newStart.getTime() - existingEnd.getTime()) / (1000 * 60));
}
// If new event is before existing event
else if (newEnd <= existingStart) {
return Math.floor((existingStart.getTime() - newEnd.getTime()) / (1000 * 60));
}
// Events overlap
return null;
}
// Generate summary message for driver status
getDriverStatusSummary(availability: DriverAvailability): string {
switch (availability.status) {
case 'available':
return `✅ Fully available (${availability.assignmentCount} assignments)`;
case 'scheduled':
return `🟡 Has ${availability.assignmentCount} assignment(s) but available for this time`;
case 'tight_turnaround':
const tightConflict = availability.conflicts.find(c => c.type === 'tight_turnaround');
return `⚡ Tight turnaround - ${tightConflict?.timeDifference} min between events`;
case 'overlapping':
return `🔴 Time conflict with existing assignment`;
default:
return 'Unknown status';
}
}
}
export default new DriverConflictService();
export { DriverAvailability, ConflictInfo, ScheduleEvent };

View File

@@ -0,0 +1,679 @@
import pool from '../config/database';
import databaseService from './databaseService';
interface VipData {
id: string;
name: string;
organization: string;
department?: string;
transportMode: 'flight' | 'self-driving';
expectedArrival?: string;
needsAirportPickup?: boolean;
needsVenueTransport: boolean;
notes?: string;
flights?: Array<{
flightNumber: string;
flightDate: string;
segment: number;
}>;
}
interface DriverData {
id: string;
name: string;
phone: string;
department?: string;
currentLocation?: { lat: number; lng: number };
assignedVipIds?: string[];
}
interface ScheduleEventData {
id: string;
title: string;
location: string;
startTime: string;
endTime: string;
description?: string;
assignedDriverId?: string;
status: string;
type: string;
}
class EnhancedDataService {
// VIP operations
async getVips(): Promise<VipData[]> {
try {
const query = `
SELECT v.*,
COALESCE(
json_agg(
json_build_object(
'flightNumber', f.flight_number,
'flightDate', f.flight_date,
'segment', f.segment
) ORDER BY f.segment
) FILTER (WHERE f.id IS NOT NULL),
'[]'::json
) as flights
FROM vips v
LEFT JOIN flights f ON v.id = f.vip_id
GROUP BY v.id
ORDER BY v.name
`;
const result = await pool.query(query);
return result.rows.map(row => ({
id: row.id,
name: row.name,
organization: row.organization,
department: row.department,
transportMode: row.transport_mode,
expectedArrival: row.expected_arrival,
needsAirportPickup: row.needs_airport_pickup,
needsVenueTransport: row.needs_venue_transport,
notes: row.notes,
flights: row.flights
}));
} catch (error) {
console.error('❌ Error fetching VIPs:', error);
throw error;
}
}
async addVip(vip: VipData): Promise<VipData> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Insert VIP
const vipQuery = `
INSERT INTO vips (id, name, organization, department, transport_mode, expected_arrival, needs_airport_pickup, needs_venue_transport, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`;
const vipResult = await client.query(vipQuery, [
vip.id,
vip.name,
vip.organization,
vip.department || 'Office of Development',
vip.transportMode,
vip.expectedArrival || null,
vip.needsAirportPickup || false,
vip.needsVenueTransport,
vip.notes || ''
]);
// Insert flights if any
if (vip.flights && vip.flights.length > 0) {
for (const flight of vip.flights) {
const flightQuery = `
INSERT INTO flights (vip_id, flight_number, flight_date, segment)
VALUES ($1, $2, $3, $4)
`;
await client.query(flightQuery, [
vip.id,
flight.flightNumber,
flight.flightDate,
flight.segment
]);
}
}
await client.query('COMMIT');
const savedVip = {
...vip,
department: vipResult.rows[0].department,
transportMode: vipResult.rows[0].transport_mode,
expectedArrival: vipResult.rows[0].expected_arrival,
needsAirportPickup: vipResult.rows[0].needs_airport_pickup,
needsVenueTransport: vipResult.rows[0].needs_venue_transport
};
return savedVip;
} catch (error) {
await client.query('ROLLBACK');
console.error('❌ Error adding VIP:', error);
throw error;
} finally {
client.release();
}
}
async updateVip(id: string, vip: Partial<VipData>): Promise<VipData | null> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Update VIP
const vipQuery = `
UPDATE vips
SET name = $2, organization = $3, department = $4, transport_mode = $5,
expected_arrival = $6, needs_airport_pickup = $7, needs_venue_transport = $8, notes = $9
WHERE id = $1
RETURNING *
`;
const vipResult = await client.query(vipQuery, [
id,
vip.name,
vip.organization,
vip.department || 'Office of Development',
vip.transportMode,
vip.expectedArrival || null,
vip.needsAirportPickup || false,
vip.needsVenueTransport,
vip.notes || ''
]);
if (vipResult.rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
// Delete existing flights and insert new ones
await client.query('DELETE FROM flights WHERE vip_id = $1', [id]);
if (vip.flights && vip.flights.length > 0) {
for (const flight of vip.flights) {
const flightQuery = `
INSERT INTO flights (vip_id, flight_number, flight_date, segment)
VALUES ($1, $2, $3, $4)
`;
await client.query(flightQuery, [
id,
flight.flightNumber,
flight.flightDate,
flight.segment
]);
}
}
await client.query('COMMIT');
const updatedVip = {
id: vipResult.rows[0].id,
name: vipResult.rows[0].name,
organization: vipResult.rows[0].organization,
department: vipResult.rows[0].department,
transportMode: vipResult.rows[0].transport_mode,
expectedArrival: vipResult.rows[0].expected_arrival,
needsAirportPickup: vipResult.rows[0].needs_airport_pickup,
needsVenueTransport: vipResult.rows[0].needs_venue_transport,
notes: vipResult.rows[0].notes,
flights: vip.flights || []
};
return updatedVip;
} catch (error) {
await client.query('ROLLBACK');
console.error('❌ Error updating VIP:', error);
throw error;
} finally {
client.release();
}
}
async deleteVip(id: string): Promise<VipData | null> {
try {
const query = `
DELETE FROM vips WHERE id = $1 RETURNING *
`;
const result = await pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
const deletedVip = {
id: result.rows[0].id,
name: result.rows[0].name,
organization: result.rows[0].organization,
department: result.rows[0].department,
transportMode: result.rows[0].transport_mode,
expectedArrival: result.rows[0].expected_arrival,
needsAirportPickup: result.rows[0].needs_airport_pickup,
needsVenueTransport: result.rows[0].needs_venue_transport,
notes: result.rows[0].notes
};
return deletedVip;
} catch (error) {
console.error('❌ Error deleting VIP:', error);
throw error;
}
}
// Driver operations
async getDrivers(): Promise<DriverData[]> {
try {
const query = `
SELECT d.*,
COALESCE(
json_agg(DISTINCT se.vip_id) FILTER (WHERE se.vip_id IS NOT NULL),
'[]'::json
) as assigned_vip_ids
FROM drivers d
LEFT JOIN schedule_events se ON d.id = se.assigned_driver_id
GROUP BY d.id
ORDER BY d.name
`;
const result = await pool.query(query);
// Get current locations from Redis
const driversWithLocations = await Promise.all(
result.rows.map(async (row) => {
const location = await databaseService.getDriverLocation(row.id);
return {
id: row.id,
name: row.name,
phone: row.phone,
department: row.department,
currentLocation: location ? { lat: location.lat, lng: location.lng } : { lat: 0, lng: 0 },
assignedVipIds: row.assigned_vip_ids || []
};
})
);
return driversWithLocations;
} catch (error) {
console.error('❌ Error fetching drivers:', error);
throw error;
}
}
async addDriver(driver: DriverData): Promise<DriverData> {
try {
const query = `
INSERT INTO drivers (id, name, phone, department)
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const result = await pool.query(query, [
driver.id,
driver.name,
driver.phone,
driver.department || 'Office of Development'
]);
// Store location in Redis if provided
if (driver.currentLocation) {
await databaseService.updateDriverLocation(driver.id, driver.currentLocation);
}
const savedDriver = {
id: result.rows[0].id,
name: result.rows[0].name,
phone: result.rows[0].phone,
department: result.rows[0].department,
currentLocation: driver.currentLocation || { lat: 0, lng: 0 }
};
return savedDriver;
} catch (error) {
console.error('❌ Error adding driver:', error);
throw error;
}
}
async updateDriver(id: string, driver: Partial<DriverData>): Promise<DriverData | null> {
try {
const query = `
UPDATE drivers
SET name = $2, phone = $3, department = $4
WHERE id = $1
RETURNING *
`;
const result = await pool.query(query, [
id,
driver.name,
driver.phone,
driver.department || 'Office of Development'
]);
if (result.rows.length === 0) {
return null;
}
// Update location in Redis if provided
if (driver.currentLocation) {
await databaseService.updateDriverLocation(id, driver.currentLocation);
}
const updatedDriver = {
id: result.rows[0].id,
name: result.rows[0].name,
phone: result.rows[0].phone,
department: result.rows[0].department,
currentLocation: driver.currentLocation || { lat: 0, lng: 0 }
};
return updatedDriver;
} catch (error) {
console.error('❌ Error updating driver:', error);
throw error;
}
}
async deleteDriver(id: string): Promise<DriverData | null> {
try {
const query = `
DELETE FROM drivers WHERE id = $1 RETURNING *
`;
const result = await pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
const deletedDriver = {
id: result.rows[0].id,
name: result.rows[0].name,
phone: result.rows[0].phone,
department: result.rows[0].department
};
return deletedDriver;
} catch (error) {
console.error('❌ Error deleting driver:', error);
throw error;
}
}
// Schedule operations
async getSchedule(vipId: string): Promise<ScheduleEventData[]> {
try {
const query = `
SELECT * FROM schedule_events
WHERE vip_id = $1
ORDER BY start_time
`;
const result = await pool.query(query, [vipId]);
return result.rows.map(row => ({
id: row.id,
title: row.title,
location: row.location,
startTime: row.start_time,
endTime: row.end_time,
description: row.description,
assignedDriverId: row.assigned_driver_id,
status: row.status,
type: row.event_type
}));
} catch (error) {
console.error('❌ Error fetching schedule:', error);
throw error;
}
}
async addScheduleEvent(vipId: string, event: ScheduleEventData): Promise<ScheduleEventData> {
try {
const query = `
INSERT INTO schedule_events (id, vip_id, title, location, start_time, end_time, description, assigned_driver_id, status, event_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`;
const result = await pool.query(query, [
event.id,
vipId,
event.title,
event.location,
event.startTime,
event.endTime,
event.description || '',
event.assignedDriverId || null,
event.status,
event.type
]);
const savedEvent = {
id: result.rows[0].id,
title: result.rows[0].title,
location: result.rows[0].location,
startTime: result.rows[0].start_time,
endTime: result.rows[0].end_time,
description: result.rows[0].description,
assignedDriverId: result.rows[0].assigned_driver_id,
status: result.rows[0].status,
type: result.rows[0].event_type
};
return savedEvent;
} catch (error) {
console.error('❌ Error adding schedule event:', error);
throw error;
}
}
async updateScheduleEvent(vipId: string, eventId: string, event: ScheduleEventData): Promise<ScheduleEventData | null> {
try {
const query = `
UPDATE schedule_events
SET title = $3, location = $4, start_time = $5, end_time = $6, description = $7, assigned_driver_id = $8, status = $9, event_type = $10
WHERE id = $1 AND vip_id = $2
RETURNING *
`;
const result = await pool.query(query, [
eventId,
vipId,
event.title,
event.location,
event.startTime,
event.endTime,
event.description || '',
event.assignedDriverId || null,
event.status,
event.type
]);
if (result.rows.length === 0) {
return null;
}
const updatedEvent = {
id: result.rows[0].id,
title: result.rows[0].title,
location: result.rows[0].location,
startTime: result.rows[0].start_time,
endTime: result.rows[0].end_time,
description: result.rows[0].description,
assignedDriverId: result.rows[0].assigned_driver_id,
status: result.rows[0].status,
type: result.rows[0].event_type
};
return updatedEvent;
} catch (error) {
console.error('❌ Error updating schedule event:', error);
throw error;
}
}
async deleteScheduleEvent(vipId: string, eventId: string): Promise<ScheduleEventData | null> {
try {
const query = `
DELETE FROM schedule_events
WHERE id = $1 AND vip_id = $2
RETURNING *
`;
const result = await pool.query(query, [eventId, vipId]);
if (result.rows.length === 0) {
return null;
}
const deletedEvent = {
id: result.rows[0].id,
title: result.rows[0].title,
location: result.rows[0].location,
startTime: result.rows[0].start_time,
endTime: result.rows[0].end_time,
description: result.rows[0].description,
assignedDriverId: result.rows[0].assigned_driver_id,
status: result.rows[0].status,
type: result.rows[0].event_type
};
return deletedEvent;
} catch (error) {
console.error('❌ Error deleting schedule event:', error);
throw error;
}
}
async getAllSchedules(): Promise<{ [vipId: string]: ScheduleEventData[] }> {
try {
const query = `
SELECT * FROM schedule_events
ORDER BY vip_id, start_time
`;
const result = await pool.query(query);
const schedules: { [vipId: string]: ScheduleEventData[] } = {};
for (const row of result.rows) {
const vipId = row.vip_id;
if (!schedules[vipId]) {
schedules[vipId] = [];
}
schedules[vipId].push({
id: row.id,
title: row.title,
location: row.location,
startTime: row.start_time,
endTime: row.end_time,
description: row.description,
assignedDriverId: row.assigned_driver_id,
status: row.status,
type: row.event_type
});
}
return schedules;
} catch (error) {
console.error('❌ Error fetching all schedules:', error);
throw error;
}
}
// Admin settings operations
async getAdminSettings(): Promise<any> {
try {
const query = `
SELECT setting_key, setting_value FROM admin_settings
`;
const result = await pool.query(query);
// Default settings structure
const defaultSettings = {
apiKeys: {
aviationStackKey: process.env.AVIATIONSTACK_API_KEY || '',
googleMapsKey: '',
twilioKey: '',
auth0Domain: process.env.AUTH0_DOMAIN || '',
auth0ClientId: process.env.AUTH0_CLIENT_ID || '',
auth0ClientSecret: process.env.AUTH0_CLIENT_SECRET || '',
auth0Audience: process.env.AUTH0_AUDIENCE || ''
},
systemSettings: {
defaultPickupLocation: '',
defaultDropoffLocation: '',
timeZone: 'America/New_York',
notificationsEnabled: false
}
};
// If no settings exist, return defaults
if (result.rows.length === 0) {
return defaultSettings;
}
// Reconstruct nested object from flattened keys
const settings: any = { ...defaultSettings };
for (const row of result.rows) {
const keys = row.setting_key.split('.');
let current = settings;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
// Parse boolean values
let value = row.setting_value;
if (value === 'true') value = true;
else if (value === 'false') value = false;
current[keys[keys.length - 1]] = value;
}
return settings;
} catch (error) {
console.error('❌ Error fetching admin settings:', error);
throw error;
}
}
async updateAdminSettings(settings: any): Promise<void> {
try {
// Flatten settings and update
const flattenSettings = (obj: any, prefix = ''): Array<{key: string, value: string}> => {
const result: Array<{key: string, value: string}> = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
result.push(...flattenSettings(value, fullKey));
} else {
result.push({ key: fullKey, value: String(value) });
}
}
return result;
};
const flatSettings = flattenSettings(settings);
for (const setting of flatSettings) {
const query = `
INSERT INTO admin_settings (setting_key, setting_value)
VALUES ($1, $2)
ON CONFLICT (setting_key) DO UPDATE SET setting_value = $2
`;
await pool.query(query, [setting.key, setting.value]);
}
} catch (error) {
console.error('❌ Error updating admin settings:', error);
throw error;
}
}
}
export default new EnhancedDataService();

View File

@@ -0,0 +1,262 @@
// Real Flight tracking service with Google scraping
// No mock data - only real flight information
interface FlightData {
flightNumber: string;
flightDate: string;
status: string;
airline?: string;
aircraft?: string;
departure: {
airport: string;
airportName?: string;
scheduled: string;
estimated?: string;
actual?: string;
terminal?: string;
gate?: string;
};
arrival: {
airport: string;
airportName?: string;
scheduled: string;
estimated?: string;
actual?: string;
terminal?: string;
gate?: string;
};
delay?: number;
lastUpdated: string;
source: 'google' | 'aviationstack' | 'not_found';
}
interface FlightSearchParams {
flightNumber: string;
date: string; // YYYY-MM-DD format
departureAirport?: string;
arrivalAirport?: string;
}
class FlightService {
private flightCache: Map<string, { data: FlightData; expires: number }> = new Map();
private updateIntervals: Map<string, NodeJS.Timeout> = new Map();
constructor() {
// No API keys needed for Google scraping
}
// Real flight lookup - no mock data
async getFlightInfo(params: FlightSearchParams): Promise<FlightData | null> {
const cacheKey = `${params.flightNumber}_${params.date}`;
// Check cache first (shorter cache for real data)
const cached = this.flightCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.data;
}
try {
// Try Google scraping first
let flightData = await this.scrapeGoogleFlights(params);
// If Google fails, try AviationStack (if API key available)
if (!flightData) {
flightData = await this.getFromAviationStack(params);
}
// Cache the result for 2 minutes (shorter for real data)
if (flightData) {
this.flightCache.set(cacheKey, {
data: flightData,
expires: Date.now() + (2 * 60 * 1000)
});
}
return flightData;
} catch (error) {
console.error('Error fetching flight data:', error);
return null; // Return null instead of mock data
}
}
// Google Flights scraping implementation
private async scrapeGoogleFlights(params: FlightSearchParams): Promise<FlightData | null> {
try {
// Google Flights URL format
const googleUrl = `https://www.google.com/travel/flights/search?tfs=CBwQAhoeEgoyMDI1LTA3LTAxagcIARIDTEFYcgcIARIDSkZLQAFIAXABggELCP___________wFAAUgBmAEB&hl=en`;
// For now, return null to indicate no real scraping implementation
// In production, you would implement actual web scraping here
console.log(`Would scrape Google for flight ${params.flightNumber} on ${params.date}`);
return null;
} catch (error) {
console.error('Google scraping error:', error);
return null;
}
}
// AviationStack API integration (only if API key available)
private async getFromAviationStack(params: FlightSearchParams): Promise<FlightData | null> {
const apiKey = process.env.AVIATIONSTACK_API_KEY;
console.log('Checking AviationStack API key:', apiKey ? `Key present (${apiKey.length} chars)` : 'No key');
if (!apiKey || apiKey === 'demo_key' || apiKey === '') {
console.log('No valid AviationStack API key available');
return null; // No API key available
}
try {
// Format flight number: Remove spaces and convert to uppercase
const formattedFlightNumber = params.flightNumber.replace(/\s+/g, '').toUpperCase();
console.log(`Formatted flight number: ${params.flightNumber} -> ${formattedFlightNumber}`);
// Note: Free tier doesn't support date filtering, so we get recent flights
// For future dates, this won't work well - consider upgrading subscription
const url = `http://api.aviationstack.com/v1/flights?access_key=${apiKey}&flight_iata=${formattedFlightNumber}&limit=10`;
console.log('AviationStack API URL:', url.replace(apiKey, '***'));
console.log('Note: Free tier returns recent flights only, not future scheduled flights');
const response = await fetch(url);
const data: any = await response.json();
console.log('AviationStack response status:', response.status);
if (!response.ok) {
console.error('AviationStack API error - HTTP status:', response.status);
return null;
}
// Check for API errors in response
if (data?.error) {
console.error('AviationStack API error:', data.error);
return null;
}
if (Array.isArray(data?.data) && data.data.length > 0) {
// This is a valid flight number that exists!
console.log(`✅ Valid flight number: ${formattedFlightNumber} exists in the system`);
// Try to find a flight matching the requested date
let flight = data.data.find((f: any) => f.flight_date === params.date);
// If no exact date match, use most recent for validation
if (!flight) {
flight = data.data[0];
console.log(` Flight ${formattedFlightNumber} is valid`);
console.log(`Recent flight: ${flight.departure.airport}${flight.arrival.airport}`);
console.log(`Operated by: ${flight.airline?.name || 'Unknown'}`);
console.log(`Note: Showing recent data from ${flight.flight_date} for validation`);
} else {
console.log(`✅ Flight found for exact date: ${params.date}`);
}
console.log('Flight route:', `${flight.departure.iata}${flight.arrival.iata}`);
console.log('Status:', flight.flight_status);
return {
flightNumber: flight.flight.iata,
flightDate: flight.flight_date,
status: this.normalizeStatus(flight.flight_status),
airline: flight.airline?.name,
aircraft: flight.aircraft?.registration,
departure: {
airport: flight.departure.iata,
airportName: flight.departure.airport,
scheduled: flight.departure.scheduled,
estimated: flight.departure.estimated,
actual: flight.departure.actual,
terminal: flight.departure.terminal,
gate: flight.departure.gate
},
arrival: {
airport: flight.arrival.iata,
airportName: flight.arrival.airport,
scheduled: flight.arrival.scheduled,
estimated: flight.arrival.estimated,
actual: flight.arrival.actual,
terminal: flight.arrival.terminal,
gate: flight.arrival.gate
},
delay: flight.departure.delay || 0,
lastUpdated: new Date().toISOString(),
source: 'aviationstack'
};
}
console.log(`❌ Invalid flight number: ${formattedFlightNumber} not found`);
console.log('This flight number does not exist or has not operated recently');
return null;
} catch (error) {
console.error('AviationStack API error:', error);
return null;
}
}
// Start periodic updates for a flight
startPeriodicUpdates(params: FlightSearchParams, intervalMinutes: number = 5): void {
const key = `${params.flightNumber}_${params.date}`;
// Clear existing interval if any
this.stopPeriodicUpdates(key);
// Set up new interval
const interval = setInterval(async () => {
try {
await this.getFlightInfo(params); // This will update the cache
console.log(`Updated flight data for ${params.flightNumber} on ${params.date}`);
} catch (error) {
console.error(`Error updating flight ${params.flightNumber}:`, error);
}
}, intervalMinutes * 60 * 1000);
this.updateIntervals.set(key, interval);
}
// Stop periodic updates for a flight
stopPeriodicUpdates(key: string): void {
const interval = this.updateIntervals.get(key);
if (interval) {
clearInterval(interval);
this.updateIntervals.delete(key);
}
}
// Get multiple flights with date specificity
async getMultipleFlights(flightParams: FlightSearchParams[]): Promise<{ [key: string]: FlightData | null }> {
const results: { [key: string]: FlightData | null } = {};
for (const params of flightParams) {
const key = `${params.flightNumber}_${params.date}`;
results[key] = await this.getFlightInfo(params);
}
return results;
}
// Normalize flight status across different APIs
private normalizeStatus(status: string): string {
const statusMap: { [key: string]: string } = {
'scheduled': 'scheduled',
'active': 'active',
'landed': 'landed',
'cancelled': 'cancelled',
'incident': 'delayed',
'diverted': 'diverted'
};
return statusMap[status.toLowerCase()] || status;
}
// Clean up resources
cleanup(): void {
for (const [key, interval] of this.updateIntervals) {
clearInterval(interval);
}
this.updateIntervals.clear();
this.flightCache.clear();
}
}
export default new FlightService();
export { FlightData, FlightSearchParams };

View File

@@ -0,0 +1,284 @@
// Flight Tracking Scheduler Service
// Efficiently batches flight API calls and manages tracking schedules
interface ScheduledFlight {
vipId: string;
vipName: string;
flightNumber: string;
flightDate: string;
segment: number;
scheduledDeparture?: string;
lastChecked?: Date;
nextCheck?: Date;
status?: string;
hasLanded?: boolean;
}
interface TrackingSchedule {
[date: string]: ScheduledFlight[];
}
class FlightTrackingScheduler {
private trackingSchedule: TrackingSchedule = {};
private checkIntervals: Map<string, NodeJS.Timeout> = new Map();
private flightService: any;
constructor(flightService: any) {
this.flightService = flightService;
}
// Add flights for a VIP to the tracking schedule
addVipFlights(vipId: string, vipName: string, flights: any[]) {
flights.forEach(flight => {
const key = flight.flightDate;
if (!this.trackingSchedule[key]) {
this.trackingSchedule[key] = [];
}
// Check if this flight is already being tracked
const existingIndex = this.trackingSchedule[key].findIndex(
f => f.flightNumber === flight.flightNumber && f.vipId === vipId
);
const scheduledFlight: ScheduledFlight = {
vipId,
vipName,
flightNumber: flight.flightNumber,
flightDate: flight.flightDate,
segment: flight.segment,
scheduledDeparture: flight.validationData?.departure?.scheduled
};
if (existingIndex >= 0) {
// Update existing entry
this.trackingSchedule[key][existingIndex] = scheduledFlight;
} else {
// Add new entry
this.trackingSchedule[key].push(scheduledFlight);
}
});
// Start or update tracking for affected dates
this.updateTrackingSchedules();
}
// Remove VIP flights from tracking
removeVipFlights(vipId: string) {
Object.keys(this.trackingSchedule).forEach(date => {
this.trackingSchedule[date] = this.trackingSchedule[date].filter(
f => f.vipId !== vipId
);
// Remove empty dates
if (this.trackingSchedule[date].length === 0) {
delete this.trackingSchedule[date];
}
});
this.updateTrackingSchedules();
}
// Update tracking schedules based on current flights
private updateTrackingSchedules() {
// Clear existing intervals
this.checkIntervals.forEach(interval => clearInterval(interval));
this.checkIntervals.clear();
// Set up tracking for each date
Object.keys(this.trackingSchedule).forEach(date => {
this.setupDateTracking(date);
});
}
// Set up tracking for a specific date
private setupDateTracking(date: string) {
const flights = this.trackingSchedule[date];
if (!flights || flights.length === 0) return;
// Check if we should start tracking (4 hours before first flight)
const now = new Date();
const dateObj = new Date(date + 'T00:00:00');
// Find earliest departure time
let earliestDeparture: Date | null = null;
flights.forEach(flight => {
if (flight.scheduledDeparture) {
const depTime = new Date(flight.scheduledDeparture);
if (!earliestDeparture || depTime < earliestDeparture) {
earliestDeparture = depTime;
}
}
});
// If no departure times, assume noon
if (!earliestDeparture) {
earliestDeparture = new Date(date + 'T12:00:00');
}
// Start tracking 4 hours before earliest departure
const trackingStartTime = new Date(earliestDeparture.getTime() - 4 * 60 * 60 * 1000);
// If tracking should have started, begin immediately
if (now >= trackingStartTime) {
this.performBatchCheck(date);
// Set up recurring checks every 60 minutes (or 30 if any delays)
const interval = setInterval(() => {
this.performBatchCheck(date);
}, 60 * 60 * 1000); // 60 minutes
this.checkIntervals.set(date, interval);
} else {
// Schedule first check for tracking start time
const timeUntilStart = trackingStartTime.getTime() - now.getTime();
setTimeout(() => {
this.performBatchCheck(date);
// Then set up recurring checks
const interval = setInterval(() => {
this.performBatchCheck(date);
}, 60 * 60 * 1000);
this.checkIntervals.set(date, interval);
}, timeUntilStart);
}
}
// Perform batch check for all flights on a date
private async performBatchCheck(date: string) {
const flights = this.trackingSchedule[date];
if (!flights || flights.length === 0) return;
console.log(`\n=== Batch Flight Check for ${date} ===`);
console.log(`Checking ${flights.length} flights...`);
// Filter out flights that have already landed
const activeFlights = flights.filter(f => !f.hasLanded);
if (activeFlights.length === 0) {
console.log('All flights have landed. Stopping tracking for this date.');
this.stopDateTracking(date);
return;
}
// Get unique flight numbers to check
const uniqueFlights = Array.from(new Set(
activeFlights.map(f => f.flightNumber)
));
console.log(`Unique flight numbers to check: ${uniqueFlights.join(', ')}`);
try {
// Make batch API call
const flightParams = uniqueFlights.map(flightNumber => ({
flightNumber,
date
}));
const results = await this.flightService.getMultipleFlights(flightParams);
// Update flight statuses
let hasDelays = false;
let allLanded = true;
activeFlights.forEach(flight => {
const key = `${flight.flightNumber}_${date}`;
const data = results[key];
if (data) {
flight.lastChecked = new Date();
flight.status = data.status;
if (data.status === 'landed') {
flight.hasLanded = true;
console.log(`${flight.flightNumber} has landed`);
} else {
allLanded = false;
if (data.delay && data.delay > 0) {
hasDelays = true;
console.log(`⚠️ ${flight.flightNumber} is delayed by ${data.delay} minutes`);
}
}
// Log status for each VIP
console.log(` VIP: ${flight.vipName} - Flight ${flight.segment}: ${flight.flightNumber} - Status: ${data.status}`);
}
});
// Update check frequency if delays detected
if (hasDelays && this.checkIntervals.has(date)) {
console.log('Delays detected - increasing check frequency to 30 minutes');
clearInterval(this.checkIntervals.get(date)!);
const interval = setInterval(() => {
this.performBatchCheck(date);
}, 30 * 60 * 1000); // 30 minutes
this.checkIntervals.set(date, interval);
}
// Stop tracking if all flights have landed
if (allLanded) {
console.log('All flights have landed. Stopping tracking for this date.');
this.stopDateTracking(date);
}
// Calculate next check time
const nextCheckTime = new Date(Date.now() + (hasDelays ? 30 : 60) * 60 * 1000);
console.log(`Next check scheduled for: ${nextCheckTime.toLocaleTimeString()}`);
} catch (error) {
console.error('Error performing batch flight check:', error);
}
}
// Stop tracking for a specific date
private stopDateTracking(date: string) {
const interval = this.checkIntervals.get(date);
if (interval) {
clearInterval(interval);
this.checkIntervals.delete(date);
}
// Mark all flights as completed
if (this.trackingSchedule[date]) {
this.trackingSchedule[date].forEach(f => f.hasLanded = true);
}
}
// Get current tracking status
getTrackingStatus(): any {
const status: any = {};
Object.entries(this.trackingSchedule).forEach(([date, flights]) => {
const activeFlights = flights.filter(f => !f.hasLanded);
const landedFlights = flights.filter(f => f.hasLanded);
status[date] = {
totalFlights: flights.length,
activeFlights: activeFlights.length,
landedFlights: landedFlights.length,
flights: flights.map(f => ({
vipName: f.vipName,
flightNumber: f.flightNumber,
segment: f.segment,
status: f.status || 'Not checked yet',
lastChecked: f.lastChecked,
hasLanded: f.hasLanded
}))
};
});
return status;
}
// Clean up all tracking
cleanup() {
this.checkIntervals.forEach(interval => clearInterval(interval));
this.checkIntervals.clear();
this.trackingSchedule = {};
}
}
export default FlightTrackingScheduler;

View File

@@ -0,0 +1,248 @@
interface ValidationError {
field: string;
message: string;
code: string;
}
interface ScheduleEvent {
title: string;
startTime: string;
endTime: string;
location: string;
type: string;
}
class ScheduleValidationService {
// Validate a single schedule event
validateEvent(event: ScheduleEvent, isEdit: boolean = false): ValidationError[] {
const errors: ValidationError[] = [];
const now = new Date();
const startTime = new Date(event.startTime);
const endTime = new Date(event.endTime);
// 1. Check if dates are valid
if (isNaN(startTime.getTime())) {
errors.push({
field: 'startTime',
message: 'Start time is not a valid date',
code: 'INVALID_START_DATE'
});
}
if (isNaN(endTime.getTime())) {
errors.push({
field: 'endTime',
message: 'End time is not a valid date',
code: 'INVALID_END_DATE'
});
}
// If dates are invalid, return early
if (errors.length > 0) {
return errors;
}
// 2. Check if start time is in the future (with 5-minute grace period for edits)
const graceMinutes = isEdit ? 5 : 0;
const minimumStartTime = new Date(now.getTime() + (graceMinutes * 60 * 1000));
if (startTime < minimumStartTime) {
errors.push({
field: 'startTime',
message: isEdit
? 'Start time must be at least 5 minutes in the future for edits'
: 'Start time must be in the future',
code: 'START_TIME_IN_PAST'
});
}
// 3. Check if end time is after start time
if (endTime <= startTime) {
errors.push({
field: 'endTime',
message: 'End time must be after start time',
code: 'END_BEFORE_START'
});
}
// 4. Check minimum event duration (5 minutes)
const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
if (durationMinutes < 5) {
errors.push({
field: 'endTime',
message: 'Event must be at least 5 minutes long',
code: 'DURATION_TOO_SHORT'
});
}
// 5. Check maximum event duration (24 hours)
if (durationMinutes > (24 * 60)) {
errors.push({
field: 'endTime',
message: 'Event cannot be longer than 24 hours',
code: 'DURATION_TOO_LONG'
});
}
// 6. Check if end time is in the future
if (endTime < now) {
errors.push({
field: 'endTime',
message: 'End time must be in the future',
code: 'END_TIME_IN_PAST'
});
}
// 7. Validate required fields
if (!event.title || event.title.trim().length === 0) {
errors.push({
field: 'title',
message: 'Event title is required',
code: 'TITLE_REQUIRED'
});
}
if (!event.location || event.location.trim().length === 0) {
errors.push({
field: 'location',
message: 'Event location is required',
code: 'LOCATION_REQUIRED'
});
}
if (!event.type || event.type.trim().length === 0) {
errors.push({
field: 'type',
message: 'Event type is required',
code: 'TYPE_REQUIRED'
});
}
// 8. Validate title length
if (event.title && event.title.length > 100) {
errors.push({
field: 'title',
message: 'Event title cannot exceed 100 characters',
code: 'TITLE_TOO_LONG'
});
}
// 9. Validate location length
if (event.location && event.location.length > 200) {
errors.push({
field: 'location',
message: 'Event location cannot exceed 200 characters',
code: 'LOCATION_TOO_LONG'
});
}
// 10. Check for reasonable scheduling (not more than 2 years in the future)
const twoYearsFromNow = new Date();
twoYearsFromNow.setFullYear(twoYearsFromNow.getFullYear() + 2);
if (startTime > twoYearsFromNow) {
errors.push({
field: 'startTime',
message: 'Event cannot be scheduled more than 2 years in the future',
code: 'START_TIME_TOO_FAR'
});
}
// 11. Check for business hours validation (optional warning)
const startHour = startTime.getHours();
const endHour = endTime.getHours();
if (startHour < 6 || startHour > 23) {
// This is a warning, not an error - we'll add it but with a different severity
errors.push({
field: 'startTime',
message: 'Event starts outside typical business hours (6 AM - 11 PM)',
code: 'OUTSIDE_BUSINESS_HOURS'
});
}
return errors;
}
// Validate multiple events for conflicts and logical sequencing
validateEventSequence(events: ScheduleEvent[]): ValidationError[] {
const errors: ValidationError[] = [];
// Sort events by start time
const sortedEvents = events
.map((event, index) => ({ ...event, originalIndex: index }))
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
// Check for overlapping events
for (let i = 0; i < sortedEvents.length - 1; i++) {
const currentEvent = sortedEvents[i];
const nextEvent = sortedEvents[i + 1];
const currentEnd = new Date(currentEvent.endTime);
const nextStart = new Date(nextEvent.startTime);
if (currentEnd > nextStart) {
errors.push({
field: 'schedule',
message: `Event "${currentEvent.title}" overlaps with "${nextEvent.title}"`,
code: 'EVENTS_OVERLAP'
});
}
}
return errors;
}
// Get user-friendly error messages
getErrorSummary(errors: ValidationError[]): string {
if (errors.length === 0) return '';
const errorMessages = errors.map(error => error.message);
if (errors.length === 1) {
return errorMessages[0];
}
return `Multiple validation errors:\n• ${errorMessages.join('\n• ')}`;
}
// Check if errors are warnings vs critical errors
isCriticalError(error: ValidationError): boolean {
const warningCodes = ['OUTSIDE_BUSINESS_HOURS'];
return !warningCodes.includes(error.code);
}
// Separate critical errors from warnings
categorizeErrors(errors: ValidationError[]): { critical: ValidationError[], warnings: ValidationError[] } {
const critical: ValidationError[] = [];
const warnings: ValidationError[] = [];
errors.forEach(error => {
if (this.isCriticalError(error)) {
critical.push(error);
} else {
warnings.push(error);
}
});
return { critical, warnings };
}
// Validate time format and suggest corrections
validateTimeFormat(timeString: string): { isValid: boolean, suggestion?: string } {
const date = new Date(timeString);
if (isNaN(date.getTime())) {
return {
isValid: false,
suggestion: 'Please use format: YYYY-MM-DDTHH:MM (e.g., 2025-07-01T14:30)'
};
}
return { isValid: true };
}
}
export default new ScheduleValidationService();
export { ValidationError, ScheduleEvent };