Initial commit - Current state of vip-coordinator
This commit is contained in:
25
backend/src/config/database.ts
Normal file
25
backend/src/config/database.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Pool } from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const useSSL = process.env.DATABASE_SSL === 'true';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:changeme@localhost:5432/vip_coordinator',
|
||||
ssl: useSSL ? { rejectUnauthorized: false } : false,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
// Test the connection
|
||||
pool.on('connect', () => {
|
||||
console.log('✅ Connected to PostgreSQL database');
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('❌ PostgreSQL connection error:', err);
|
||||
});
|
||||
|
||||
export default pool;
|
||||
23
backend/src/config/redis.ts
Normal file
23
backend/src/config/redis.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createClient } from 'redis';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const redisClient = createClient({
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379'
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
console.log('✅ Connected to Redis');
|
||||
});
|
||||
|
||||
redisClient.on('error', (err: Error) => {
|
||||
console.error('❌ Redis connection error:', err);
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
redisClient.connect().catch((err: Error) => {
|
||||
console.error('❌ Failed to connect to Redis:', err);
|
||||
});
|
||||
|
||||
export default redisClient;
|
||||
130
backend/src/config/schema.sql
Normal file
130
backend/src/config/schema.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- VIP Coordinator Database Schema
|
||||
|
||||
-- Create VIPs table
|
||||
CREATE TABLE IF NOT EXISTS vips (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
organization VARCHAR(255) NOT NULL,
|
||||
department VARCHAR(255) DEFAULT 'Office of Development',
|
||||
transport_mode VARCHAR(50) NOT NULL CHECK (transport_mode IN ('flight', 'self-driving')),
|
||||
expected_arrival TIMESTAMP,
|
||||
needs_airport_pickup BOOLEAN DEFAULT false,
|
||||
needs_venue_transport BOOLEAN DEFAULT true,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create flights table (for VIPs with flight transport)
|
||||
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
|
||||
);
|
||||
|
||||
-- Create drivers table
|
||||
CREATE TABLE IF NOT EXISTS drivers (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50) NOT NULL,
|
||||
department VARCHAR(255) DEFAULT 'Office of Development',
|
||||
user_id VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create schedule_events table
|
||||
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
|
||||
);
|
||||
|
||||
-- Create users table for authentication
|
||||
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
|
||||
);
|
||||
|
||||
-- Create system_setup table for tracking initial setup
|
||||
CREATE TABLE IF NOT EXISTS system_setup (
|
||||
id SERIAL PRIMARY KEY,
|
||||
setup_completed BOOLEAN DEFAULT false,
|
||||
first_admin_created BOOLEAN DEFAULT false,
|
||||
setup_date TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create admin_settings table
|
||||
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
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_vips_transport_mode ON vips(transport_mode);
|
||||
CREATE INDEX IF NOT EXISTS idx_flights_vip_id ON flights(vip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_flights_date ON flights(flight_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_events_vip_id ON schedule_events(vip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_events_driver_id ON schedule_events(assigned_driver_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_events_start_time ON schedule_events(start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_events_status ON schedule_events(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_drivers_user_id ON drivers(user_id);
|
||||
|
||||
-- Create updated_at trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Create triggers for updated_at (drop if exists first)
|
||||
DROP TRIGGER IF EXISTS update_vips_updated_at ON vips;
|
||||
DROP TRIGGER IF EXISTS update_flights_updated_at ON flights;
|
||||
DROP TRIGGER IF EXISTS update_drivers_updated_at ON drivers;
|
||||
DROP TRIGGER IF EXISTS update_schedule_events_updated_at ON schedule_events;
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
DROP TRIGGER IF EXISTS update_admin_settings_updated_at ON admin_settings;
|
||||
|
||||
CREATE TRIGGER update_vips_updated_at BEFORE UPDATE ON vips FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_flights_updated_at BEFORE UPDATE ON flights FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_drivers_updated_at BEFORE UPDATE ON drivers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_schedule_events_updated_at BEFORE UPDATE ON schedule_events FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_admin_settings_updated_at BEFORE UPDATE ON admin_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
178
backend/src/config/simpleAuth.ts
Normal file
178
backend/src/config/simpleAuth.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import jwt, { JwtHeader, JwtPayload } from 'jsonwebtoken';
|
||||
import jwksClient from 'jwks-rsa';
|
||||
|
||||
const auth0Domain = process.env.AUTH0_DOMAIN;
|
||||
const auth0Audience = process.env.AUTH0_AUDIENCE;
|
||||
|
||||
if (!auth0Domain) {
|
||||
console.warn('⚠️ AUTH0_DOMAIN is not set. Authentication routes will reject requests until configured.');
|
||||
}
|
||||
|
||||
if (!auth0Audience) {
|
||||
console.warn('⚠️ AUTH0_AUDIENCE is not set. Authentication routes will reject requests until configured.');
|
||||
}
|
||||
|
||||
const jwks = auth0Domain
|
||||
? jwksClient({
|
||||
jwksUri: `https://${auth0Domain}/.well-known/jwks.json`,
|
||||
cache: true,
|
||||
cacheMaxEntries: 5,
|
||||
cacheMaxAge: 10 * 60 * 1000
|
||||
})
|
||||
: null;
|
||||
|
||||
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const profileCache = new Map<string, { profile: Auth0UserProfile; expiresAt: number }>();
|
||||
const inflightProfileRequests = new Map<string, Promise<Auth0UserProfile>>();
|
||||
|
||||
export interface Auth0UserProfile {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
nickname?: string;
|
||||
picture?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VerifiedAccessToken extends JwtPayload {
|
||||
sub: string;
|
||||
azp?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
async function getSigningKey(header: JwtHeader): Promise<string> {
|
||||
if (!jwks) {
|
||||
throw new Error('Auth0 JWKS client not initialised');
|
||||
}
|
||||
|
||||
if (!header.kid) {
|
||||
throw new Error('Token signing key id (kid) is missing');
|
||||
}
|
||||
|
||||
const signingKey = await new Promise<jwksClient.SigningKey>((resolve, reject) => {
|
||||
jwks.getSigningKey(header.kid as string, (err, key) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
if (!key) {
|
||||
return reject(new Error('Signing key not found'));
|
||||
}
|
||||
resolve(key);
|
||||
});
|
||||
});
|
||||
|
||||
const publicKey =
|
||||
typeof signingKey.getPublicKey === 'function'
|
||||
? signingKey.getPublicKey()
|
||||
: (signingKey as any).publicKey || (signingKey as any).rsaPublicKey;
|
||||
|
||||
if (!publicKey) {
|
||||
throw new Error('Unable to derive signing key');
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<VerifiedAccessToken> {
|
||||
if (!auth0Domain || !auth0Audience) {
|
||||
throw new Error('Auth0 configuration is incomplete');
|
||||
}
|
||||
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded || typeof decoded === 'string') {
|
||||
throw new Error('Invalid JWT');
|
||||
}
|
||||
|
||||
const signingKey = await getSigningKey(decoded.header);
|
||||
|
||||
return jwt.verify(token, signingKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience: auth0Audience,
|
||||
issuer: `https://${auth0Domain}/`
|
||||
}) as VerifiedAccessToken;
|
||||
}
|
||||
|
||||
export async function fetchAuth0UserProfile(accessToken: string, cacheKey: string, expiresAt?: number): Promise<Auth0UserProfile> {
|
||||
if (!auth0Domain) {
|
||||
throw new Error('Auth0 configuration is incomplete');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cached = profileCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.profile;
|
||||
}
|
||||
|
||||
const ttl = expiresAt ? Math.max(0, expiresAt * 1000 - now) : PROFILE_CACHE_TTL_MS;
|
||||
if (inflightProfileRequests.has(cacheKey)) {
|
||||
return inflightProfileRequests.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const response = await fetch(`https://${auth0Domain}/userinfo`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Auth0 user profile (${response.status})`);
|
||||
}
|
||||
|
||||
const profile = (await response.json()) as Auth0UserProfile;
|
||||
profileCache.set(cacheKey, { profile, expiresAt: now + ttl });
|
||||
inflightProfileRequests.delete(cacheKey);
|
||||
return profile;
|
||||
})().catch(error => {
|
||||
inflightProfileRequests.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
inflightProfileRequests.set(cacheKey, fetchPromise);
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
export function clearAuth0ProfileCache(cacheKey?: string) {
|
||||
if (cacheKey) {
|
||||
profileCache.delete(cacheKey);
|
||||
inflightProfileRequests.delete(cacheKey);
|
||||
} else {
|
||||
profileCache.clear();
|
||||
inflightProfileRequests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedProfile(cacheKey: string): Auth0UserProfile | undefined {
|
||||
const cached = profileCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.profile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function cacheAuth0Profile(cacheKey: string, profile: Auth0UserProfile, expiresAt?: number) {
|
||||
const ttl = expiresAt ? Math.max(0, expiresAt * 1000 - Date.now()) : PROFILE_CACHE_TTL_MS;
|
||||
profileCache.set(cacheKey, { profile, expiresAt: Date.now() + ttl });
|
||||
}
|
||||
|
||||
export async function fetchFreshAuth0Profile(accessToken: string): Promise<Auth0UserProfile> {
|
||||
if (!auth0Domain) {
|
||||
throw new Error('Auth0 configuration is incomplete');
|
||||
}
|
||||
|
||||
const response = await fetch(`https://${auth0Domain}/userinfo`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Auth0 user profile (${response.status})`);
|
||||
}
|
||||
|
||||
return (await response.json()) as Auth0UserProfile;
|
||||
}
|
||||
|
||||
export function isAuth0Configured(): boolean {
|
||||
return Boolean(auth0Domain && auth0Audience);
|
||||
}
|
||||
740
backend/src/index.ts
Normal file
740
backend/src/index.ts
Normal file
@@ -0,0 +1,740 @@
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import dotenv from 'dotenv';
|
||||
import cors from 'cors';
|
||||
import authRoutes, { requireAuth, requireRole } from './routes/simpleAuth';
|
||||
import flightService from './services/flightService';
|
||||
import driverConflictService from './services/driverConflictService';
|
||||
import scheduleValidationService from './services/scheduleValidationService';
|
||||
import FlightTrackingScheduler from './services/flightTrackingScheduler';
|
||||
import enhancedDataService from './services/enhancedDataService';
|
||||
import databaseService from './services/databaseService';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app: Express = express();
|
||||
const port: number = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: [
|
||||
process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
'https://bsa.madeamess.online:5173',
|
||||
'http://bsa.madeamess.online:5173'
|
||||
],
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Simple JWT-based authentication - no passport needed
|
||||
|
||||
// Authentication routes
|
||||
app.use('/auth', authRoutes);
|
||||
|
||||
// Temporary admin bypass route (remove after setup)
|
||||
app.get('/admin-bypass', (req: Request, res: Response) => {
|
||||
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5173'}/admin?bypass=true`);
|
||||
});
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req: Request, res: Response) => {
|
||||
res.json({ status: 'OK', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Data is now persisted using dataService - no more in-memory storage!
|
||||
|
||||
// Initialize flight tracking scheduler
|
||||
const flightTracker = new FlightTrackingScheduler(flightService);
|
||||
|
||||
// VIP routes (protected)
|
||||
app.post('/api/vips', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
// Create a new VIP
|
||||
const {
|
||||
name,
|
||||
organization,
|
||||
department, // New: Office of Development or Admin
|
||||
transportMode,
|
||||
flightNumber, // Legacy single flight
|
||||
flights, // New: array of flights
|
||||
expectedArrival,
|
||||
needsAirportPickup,
|
||||
needsVenueTransport,
|
||||
notes
|
||||
} = req.body;
|
||||
|
||||
const newVip = {
|
||||
id: Date.now().toString(), // Simple ID generation
|
||||
name,
|
||||
organization,
|
||||
department: department || 'Office of Development', // Default to Office of Development
|
||||
transportMode: transportMode || 'flight',
|
||||
// Support both legacy single flight and new multiple flights
|
||||
flightNumber: transportMode === 'flight' && !flights ? flightNumber : undefined,
|
||||
flights: transportMode === 'flight' && flights ? flights : undefined,
|
||||
expectedArrival: transportMode === 'self-driving' ? expectedArrival : undefined,
|
||||
arrivalTime: transportMode === 'flight' ? undefined : expectedArrival, // Legacy field for flight arrivals
|
||||
needsAirportPickup: transportMode === 'flight' ? (needsAirportPickup !== false) : false,
|
||||
needsVenueTransport: needsVenueTransport !== false, // Default to true
|
||||
assignedDriverIds: [],
|
||||
notes: notes || '',
|
||||
schedule: []
|
||||
};
|
||||
|
||||
const savedVip = await enhancedDataService.addVip(newVip);
|
||||
|
||||
// Add flights to tracking scheduler if applicable
|
||||
if (savedVip.transportMode === 'flight' && savedVip.flights && savedVip.flights.length > 0) {
|
||||
flightTracker.addVipFlights(savedVip.id, savedVip.name, savedVip.flights);
|
||||
}
|
||||
|
||||
res.status(201).json(savedVip);
|
||||
});
|
||||
|
||||
app.get('/api/vips', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Fetch all VIPs
|
||||
const vips = await enhancedDataService.getVips();
|
||||
res.json(vips);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch VIPs' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/vips/:id', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
// Update a VIP
|
||||
const { id } = req.params;
|
||||
const {
|
||||
name,
|
||||
organization,
|
||||
department, // New: Office of Development or Admin
|
||||
transportMode,
|
||||
flightNumber, // Legacy single flight
|
||||
flights, // New: array of flights
|
||||
expectedArrival,
|
||||
needsAirportPickup,
|
||||
needsVenueTransport,
|
||||
notes
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const updatedVip = {
|
||||
name,
|
||||
organization,
|
||||
department: department || 'Office of Development',
|
||||
transportMode: transportMode || 'flight',
|
||||
// Support both legacy single flight and new multiple flights
|
||||
flights: transportMode === 'flight' && flights ? flights : undefined,
|
||||
expectedArrival: transportMode === 'self-driving' ? expectedArrival : undefined,
|
||||
needsAirportPickup: transportMode === 'flight' ? (needsAirportPickup !== false) : false,
|
||||
needsVenueTransport: needsVenueTransport !== false,
|
||||
notes: notes || ''
|
||||
};
|
||||
|
||||
const savedVip = await enhancedDataService.updateVip(id, updatedVip);
|
||||
|
||||
if (!savedVip) {
|
||||
return res.status(404).json({ error: 'VIP not found' });
|
||||
}
|
||||
|
||||
// Update flight tracking if needed
|
||||
if (savedVip.transportMode === 'flight') {
|
||||
// Remove old flights
|
||||
flightTracker.removeVipFlights(id);
|
||||
|
||||
// Add new flights if any
|
||||
if (savedVip.flights && savedVip.flights.length > 0) {
|
||||
flightTracker.addVipFlights(savedVip.id, savedVip.name, savedVip.flights);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(savedVip);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update VIP' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/vips/:id', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
// Delete a VIP
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const deletedVip = await enhancedDataService.deleteVip(id);
|
||||
|
||||
if (!deletedVip) {
|
||||
return res.status(404).json({ error: 'VIP not found' });
|
||||
}
|
||||
|
||||
// Remove from flight tracking
|
||||
flightTracker.removeVipFlights(id);
|
||||
|
||||
res.json({ message: 'VIP deleted successfully', vip: deletedVip });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete VIP' });
|
||||
}
|
||||
});
|
||||
|
||||
// Driver routes (protected)
|
||||
app.post('/api/drivers', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
// Create a new driver
|
||||
const { name, phone, currentLocation, department } = req.body;
|
||||
|
||||
const newDriver = {
|
||||
id: Date.now().toString(),
|
||||
name,
|
||||
phone,
|
||||
department: department || 'Office of Development', // Default to Office of Development
|
||||
currentLocation: currentLocation || { lat: 0, lng: 0 },
|
||||
assignedVipIds: []
|
||||
};
|
||||
|
||||
try {
|
||||
const savedDriver = await enhancedDataService.addDriver(newDriver);
|
||||
res.status(201).json(savedDriver);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to create driver' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/drivers', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Fetch all drivers
|
||||
const drivers = await enhancedDataService.getDrivers();
|
||||
res.json(drivers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch drivers' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/drivers/:id', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
// Update a driver
|
||||
const { id } = req.params;
|
||||
const { name, phone, currentLocation, department } = req.body;
|
||||
|
||||
try {
|
||||
const updatedDriver = {
|
||||
name,
|
||||
phone,
|
||||
department: department || 'Office of Development',
|
||||
currentLocation: currentLocation || { lat: 0, lng: 0 }
|
||||
};
|
||||
|
||||
const savedDriver = await enhancedDataService.updateDriver(id, updatedDriver);
|
||||
|
||||
if (!savedDriver) {
|
||||
return res.status(404).json({ error: 'Driver not found' });
|
||||
}
|
||||
|
||||
res.json(savedDriver);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update driver' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/drivers/:id', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
// Delete a driver
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const deletedDriver = await enhancedDataService.deleteDriver(id);
|
||||
|
||||
if (!deletedDriver) {
|
||||
return res.status(404).json({ error: 'Driver not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Driver deleted successfully', driver: deletedDriver });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete driver' });
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced flight tracking routes with date specificity
|
||||
app.get('/api/flights/:flightNumber', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flightNumber } = req.params;
|
||||
const { date, departureAirport, arrivalAirport } = req.query;
|
||||
|
||||
// Default to today if no date provided
|
||||
const flightDate = (date as string) || new Date().toISOString().split('T')[0];
|
||||
|
||||
const flightData = await flightService.getFlightInfo({
|
||||
flightNumber,
|
||||
date: flightDate,
|
||||
departureAirport: departureAirport as string,
|
||||
arrivalAirport: arrivalAirport as string
|
||||
});
|
||||
|
||||
if (flightData) {
|
||||
// Always return flight data for validation, even if date doesn't match
|
||||
res.json(flightData);
|
||||
} else {
|
||||
// Only return 404 if the flight number itself is invalid
|
||||
res.status(404).json({ error: 'Invalid flight number - this flight does not exist' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch flight data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Start periodic updates for a flight
|
||||
app.post('/api/flights/:flightNumber/track', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flightNumber } = req.params;
|
||||
const { date, intervalMinutes = 5 } = req.body;
|
||||
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Flight date is required' });
|
||||
}
|
||||
|
||||
flightService.startPeriodicUpdates({
|
||||
flightNumber,
|
||||
date
|
||||
}, intervalMinutes);
|
||||
|
||||
res.json({ message: `Started tracking ${flightNumber} on ${date}` });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to start flight tracking' });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop periodic updates for a flight
|
||||
app.delete('/api/flights/:flightNumber/track', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flightNumber } = req.params;
|
||||
const { date } = req.query;
|
||||
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Flight date is required' });
|
||||
}
|
||||
|
||||
const key = `${flightNumber}_${date}`;
|
||||
flightService.stopPeriodicUpdates(key);
|
||||
|
||||
res.json({ message: `Stopped tracking ${flightNumber} on ${date}` });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to stop flight tracking' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/flights/batch', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flights } = req.body;
|
||||
|
||||
if (!Array.isArray(flights)) {
|
||||
return res.status(400).json({ error: 'flights must be an array of {flightNumber, date} objects' });
|
||||
}
|
||||
|
||||
// Validate flight objects
|
||||
for (const flight of flights) {
|
||||
if (!flight.flightNumber || !flight.date) {
|
||||
return res.status(400).json({ error: 'Each flight must have flightNumber and date' });
|
||||
}
|
||||
}
|
||||
|
||||
const flightData = await flightService.getMultipleFlights(flights);
|
||||
res.json(flightData);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch flight data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get flight tracking status
|
||||
app.get('/api/flights/tracking/status', (req: Request, res: Response) => {
|
||||
const status = flightTracker.getTrackingStatus();
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
// Schedule management routes (protected)
|
||||
app.get('/api/vips/:vipId/schedule', requireAuth, async (req: Request, res: Response) => {
|
||||
const { vipId } = req.params;
|
||||
try {
|
||||
const vipSchedule = await enhancedDataService.getSchedule(vipId);
|
||||
res.json(vipSchedule);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch schedule' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/vips/:vipId/schedule', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
const { vipId } = req.params;
|
||||
const { title, location, startTime, endTime, description, type, assignedDriverId } = req.body;
|
||||
|
||||
// Validate the event
|
||||
const validationErrors = scheduleValidationService.validateEvent({
|
||||
title: title || '',
|
||||
location: location || '',
|
||||
startTime: startTime || '',
|
||||
endTime: endTime || '',
|
||||
type: type || ''
|
||||
}, false);
|
||||
|
||||
const { critical, warnings } = scheduleValidationService.categorizeErrors(validationErrors);
|
||||
|
||||
// Return validation errors if any critical errors exist
|
||||
if (critical.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
validationErrors: critical,
|
||||
warnings: warnings,
|
||||
message: scheduleValidationService.getErrorSummary(critical)
|
||||
});
|
||||
}
|
||||
|
||||
const newEvent = {
|
||||
id: Date.now().toString(),
|
||||
title,
|
||||
location,
|
||||
startTime,
|
||||
endTime,
|
||||
description: description || '',
|
||||
assignedDriverId: assignedDriverId || '',
|
||||
status: 'scheduled',
|
||||
type
|
||||
};
|
||||
|
||||
try {
|
||||
const savedEvent = await enhancedDataService.addScheduleEvent(vipId, newEvent);
|
||||
|
||||
// Include warnings in the response if any
|
||||
const response: any = { ...savedEvent };
|
||||
if (warnings.length > 0) {
|
||||
response.warnings = warnings;
|
||||
}
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to create schedule event' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/vips/:vipId/schedule/:eventId', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
const { vipId, eventId } = req.params;
|
||||
const { title, location, startTime, endTime, description, type, assignedDriverId, status } = req.body;
|
||||
|
||||
// Validate the updated event (with edit flag for grace period)
|
||||
const validationErrors = scheduleValidationService.validateEvent({
|
||||
title: title || '',
|
||||
location: location || '',
|
||||
startTime: startTime || '',
|
||||
endTime: endTime || '',
|
||||
type: type || ''
|
||||
}, true);
|
||||
|
||||
const { critical, warnings } = scheduleValidationService.categorizeErrors(validationErrors);
|
||||
|
||||
// Return validation errors if any critical errors exist
|
||||
if (critical.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
validationErrors: critical,
|
||||
warnings: warnings,
|
||||
message: scheduleValidationService.getErrorSummary(critical)
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEvent = {
|
||||
id: eventId,
|
||||
title,
|
||||
location,
|
||||
startTime,
|
||||
endTime,
|
||||
description: description || '',
|
||||
assignedDriverId: assignedDriverId || '',
|
||||
type,
|
||||
status: status || 'scheduled'
|
||||
};
|
||||
|
||||
try {
|
||||
const savedEvent = await enhancedDataService.updateScheduleEvent(vipId, eventId, updatedEvent);
|
||||
|
||||
if (!savedEvent) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Include warnings in the response if any
|
||||
const response: any = { ...savedEvent };
|
||||
if (warnings.length > 0) {
|
||||
response.warnings = warnings;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update schedule event' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/vips/:vipId/schedule/:eventId/status', requireAuth, async (req: Request, res: Response) => {
|
||||
const { vipId, eventId } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
try {
|
||||
const currentSchedule = await enhancedDataService.getSchedule(vipId);
|
||||
const currentEvent = currentSchedule.find((event: any) => event.id === eventId);
|
||||
|
||||
if (!currentEvent) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const updatedEvent = { ...currentEvent, status };
|
||||
const savedEvent = await enhancedDataService.updateScheduleEvent(vipId, eventId, updatedEvent);
|
||||
|
||||
if (!savedEvent) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
res.json(savedEvent);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update event status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/vips/:vipId/schedule/:eventId', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => {
|
||||
const { vipId, eventId } = req.params;
|
||||
|
||||
try {
|
||||
const deletedEvent = await enhancedDataService.deleteScheduleEvent(vipId, eventId);
|
||||
|
||||
if (!deletedEvent) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Event deleted successfully', event: deletedEvent });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete schedule event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Driver availability and conflict checking (protected)
|
||||
app.post('/api/drivers/availability', requireAuth, async (req: Request, res: Response) => {
|
||||
const { startTime, endTime, location } = req.body;
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
return res.status(400).json({ error: 'startTime and endTime are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const allSchedules = await enhancedDataService.getAllSchedules();
|
||||
const drivers = await enhancedDataService.getDrivers();
|
||||
|
||||
const availability = driverConflictService.getDriverAvailability(
|
||||
{ startTime, endTime, location: location || '' },
|
||||
allSchedules as any,
|
||||
drivers
|
||||
);
|
||||
|
||||
res.json(availability);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to check driver availability' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check conflicts for specific driver assignment (protected)
|
||||
app.post('/api/drivers/:driverId/conflicts', requireAuth, async (req: Request, res: Response) => {
|
||||
const { driverId } = req.params;
|
||||
const { startTime, endTime, location } = req.body;
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
return res.status(400).json({ error: 'startTime and endTime are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const allSchedules = await enhancedDataService.getAllSchedules();
|
||||
const drivers = await enhancedDataService.getDrivers();
|
||||
|
||||
const conflicts = driverConflictService.checkDriverConflicts(
|
||||
driverId,
|
||||
{ startTime, endTime, location: location || '' },
|
||||
allSchedules as any,
|
||||
drivers
|
||||
);
|
||||
|
||||
res.json({ conflicts });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to check driver conflicts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get driver's complete schedule (protected)
|
||||
app.get('/api/drivers/:driverId/schedule', requireAuth, async (req: Request, res: Response) => {
|
||||
const { driverId } = req.params;
|
||||
|
||||
try {
|
||||
const drivers = await enhancedDataService.getDrivers();
|
||||
const driver = drivers.find((d: any) => d.id === driverId);
|
||||
if (!driver) {
|
||||
return res.status(404).json({ error: 'Driver not found' });
|
||||
}
|
||||
|
||||
// Get all events assigned to this driver across all VIPs
|
||||
const driverSchedule: any[] = [];
|
||||
const allSchedules = await enhancedDataService.getAllSchedules();
|
||||
const vips = await enhancedDataService.getVips();
|
||||
|
||||
Object.entries(allSchedules).forEach(([vipId, events]: [string, any]) => {
|
||||
events.forEach((event: any) => {
|
||||
if (event.assignedDriverId === driverId) {
|
||||
// Get VIP name
|
||||
const vip = vips.find((v: any) => v.id === vipId);
|
||||
driverSchedule.push({
|
||||
...event,
|
||||
vipId,
|
||||
vipName: vip ? vip.name : 'Unknown VIP'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by start time
|
||||
driverSchedule.sort((a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
||||
);
|
||||
|
||||
res.json({
|
||||
driver: {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
phone: driver.phone,
|
||||
department: driver.department
|
||||
},
|
||||
schedule: driverSchedule
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch driver schedule' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin routes
|
||||
app.get('/api/admin/settings', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const adminSettings = await enhancedDataService.getAdminSettings();
|
||||
const apiKeys = adminSettings.apiKeys || {};
|
||||
|
||||
// Return settings but mask API keys for display only
|
||||
// IMPORTANT: Don't return the actual keys, just indicate they exist
|
||||
const maskedSettings = {
|
||||
apiKeys: {
|
||||
aviationStackKey: apiKeys.aviationStackKey ? '***' + apiKeys.aviationStackKey.slice(-4) : '',
|
||||
googleMapsKey: apiKeys.googleMapsKey ? '***' + apiKeys.googleMapsKey.slice(-4) : '',
|
||||
twilioKey: apiKeys.twilioKey ? '***' + apiKeys.twilioKey.slice(-4) : '',
|
||||
auth0Domain: apiKeys.auth0Domain ? '***' + apiKeys.auth0Domain.slice(-4) : '',
|
||||
auth0ClientId: apiKeys.auth0ClientId ? '***' + apiKeys.auth0ClientId.slice(-4) : '',
|
||||
auth0ClientSecret: apiKeys.auth0ClientSecret ? '***' + apiKeys.auth0ClientSecret.slice(-4) : ''
|
||||
},
|
||||
systemSettings: adminSettings.systemSettings
|
||||
};
|
||||
|
||||
res.json(maskedSettings);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch admin settings' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/settings', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { apiKeys, systemSettings } = req.body;
|
||||
const currentSettings = await enhancedDataService.getAdminSettings();
|
||||
currentSettings.apiKeys = currentSettings.apiKeys || {};
|
||||
|
||||
// Update API keys (only if provided and not masked)
|
||||
if (apiKeys) {
|
||||
if (apiKeys.aviationStackKey && !apiKeys.aviationStackKey.startsWith('***')) {
|
||||
currentSettings.apiKeys.aviationStackKey = apiKeys.aviationStackKey;
|
||||
// Update the environment variable for the flight service
|
||||
process.env.AVIATIONSTACK_API_KEY = apiKeys.aviationStackKey;
|
||||
}
|
||||
if (apiKeys.googleMapsKey && !apiKeys.googleMapsKey.startsWith('***')) {
|
||||
currentSettings.apiKeys.googleMapsKey = apiKeys.googleMapsKey;
|
||||
}
|
||||
if (apiKeys.twilioKey && !apiKeys.twilioKey.startsWith('***')) {
|
||||
currentSettings.apiKeys.twilioKey = apiKeys.twilioKey;
|
||||
}
|
||||
if (apiKeys.auth0Domain && !apiKeys.auth0Domain.startsWith('***')) {
|
||||
currentSettings.apiKeys.auth0Domain = apiKeys.auth0Domain;
|
||||
process.env.AUTH0_DOMAIN = apiKeys.auth0Domain;
|
||||
}
|
||||
if (apiKeys.auth0ClientId && !apiKeys.auth0ClientId.startsWith('***')) {
|
||||
currentSettings.apiKeys.auth0ClientId = apiKeys.auth0ClientId;
|
||||
process.env.AUTH0_CLIENT_ID = apiKeys.auth0ClientId;
|
||||
}
|
||||
if (apiKeys.auth0ClientSecret && !apiKeys.auth0ClientSecret.startsWith('***')) {
|
||||
currentSettings.apiKeys.auth0ClientSecret = apiKeys.auth0ClientSecret;
|
||||
process.env.AUTH0_CLIENT_SECRET = apiKeys.auth0ClientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Update system settings
|
||||
if (systemSettings) {
|
||||
currentSettings.systemSettings = { ...currentSettings.systemSettings, ...systemSettings };
|
||||
}
|
||||
|
||||
// Save the updated settings
|
||||
await enhancedDataService.updateAdminSettings(currentSettings);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update admin settings' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/test-api/:apiType', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
const { apiType } = req.params;
|
||||
const { apiKey } = req.body;
|
||||
|
||||
try {
|
||||
switch (apiType) {
|
||||
case 'aviationStackKey':
|
||||
// Test AviationStack API
|
||||
const testUrl = `http://api.aviationstack.com/v1/flights?access_key=${apiKey}&limit=1`;
|
||||
const response = await fetch(testUrl);
|
||||
|
||||
if (response.ok) {
|
||||
const data: any = await response.json();
|
||||
if (data.error) {
|
||||
res.status(400).json({ error: data.error.message || 'Invalid API key' });
|
||||
} else {
|
||||
res.json({ success: true, message: 'API key is valid!' });
|
||||
}
|
||||
} else {
|
||||
res.status(400).json({ error: 'Failed to validate API key' });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'googleMapsKey':
|
||||
res.json({ success: true, message: 'Google Maps API testing not yet implemented' });
|
||||
break;
|
||||
|
||||
case 'twilioKey':
|
||||
res.json({ success: true, message: 'Twilio API testing not yet implemented' });
|
||||
break;
|
||||
|
||||
default:
|
||||
res.status(400).json({ error: 'Unknown API type' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to test API connection' });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize database schema and migrate data
|
||||
await databaseService.initializeDatabase();
|
||||
console.log('✅ Database initialization completed');
|
||||
|
||||
// Start the server
|
||||
app.listen(port, () => {
|
||||
console.log(`🚀 Server is running on port ${port}`);
|
||||
console.log(`📊 Admin dashboard: http://localhost:${port === 3000 ? 5173 : port}/admin`);
|
||||
console.log(`🏥 Health check: http://localhost:${port}/api/health`);
|
||||
console.log(`📚 API docs: http://localhost:${port}/api-docs.html`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
324
backend/src/routes/simpleAuth.ts
Normal file
324
backend/src/routes/simpleAuth.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
fetchAuth0UserProfile,
|
||||
isAuth0Configured,
|
||||
verifyAccessToken,
|
||||
VerifiedAccessToken,
|
||||
Auth0UserProfile,
|
||||
getCachedProfile,
|
||||
cacheAuth0Profile
|
||||
} from '../config/simpleAuth';
|
||||
import databaseService from '../services/databaseService';
|
||||
|
||||
type AuthedRequest = Request & {
|
||||
auth?: {
|
||||
token: string;
|
||||
claims: VerifiedAccessToken;
|
||||
profile?: Auth0UserProfile | null;
|
||||
};
|
||||
user?: any;
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function mapUserForResponse(user: any) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.profile_picture_url,
|
||||
role: user.role,
|
||||
approval_status: user.approval_status,
|
||||
created_at: user.created_at,
|
||||
last_login: user.last_login,
|
||||
provider: 'auth0'
|
||||
};
|
||||
}
|
||||
|
||||
async function syncUserWithDatabase(claims: VerifiedAccessToken, token: string): Promise<{ user: any; profile: Auth0UserProfile | null }> {
|
||||
const auth0Id = claims.sub;
|
||||
const initialAdminEmails = (process.env.INITIAL_ADMIN_EMAILS || '')
|
||||
.split(',')
|
||||
.map(email => email.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
let profile: Auth0UserProfile | null = null;
|
||||
let user = await databaseService.getUserById(auth0Id);
|
||||
|
||||
if (user) {
|
||||
const updated = await databaseService.updateUserLastSignIn(user.email);
|
||||
user = updated || user;
|
||||
|
||||
const isSeedAdmin = initialAdminEmails.includes((user.email || '').toLowerCase());
|
||||
if (isSeedAdmin && user.role !== 'administrator') {
|
||||
user = await databaseService.updateUserRole(user.email, 'administrator');
|
||||
}
|
||||
if (isSeedAdmin && user.approval_status !== 'approved') {
|
||||
user = await databaseService.updateUserApprovalStatus(user.email, 'approved');
|
||||
}
|
||||
|
||||
return { user, profile };
|
||||
}
|
||||
|
||||
const cacheKey = auth0Id;
|
||||
profile = getCachedProfile(cacheKey) || null;
|
||||
|
||||
if (!profile) {
|
||||
profile = await fetchAuth0UserProfile(token, cacheKey, claims.exp);
|
||||
cacheAuth0Profile(cacheKey, profile, claims.exp);
|
||||
}
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Auth0 profile did not include an email address');
|
||||
}
|
||||
|
||||
const existingByEmail = await databaseService.getUserByEmail(profile.email);
|
||||
|
||||
if (existingByEmail && existingByEmail.id !== auth0Id) {
|
||||
await databaseService.migrateUserId(existingByEmail.id, auth0Id);
|
||||
user = await databaseService.getUserById(auth0Id);
|
||||
} else if (existingByEmail) {
|
||||
user = existingByEmail;
|
||||
}
|
||||
|
||||
const displayName = profile.name || profile.nickname || profile.email;
|
||||
const picture = typeof profile.picture === 'string' ? profile.picture : undefined;
|
||||
const isSeedAdmin = initialAdminEmails.includes(profile.email.toLowerCase());
|
||||
|
||||
if (!user) {
|
||||
const approvedUserCount = await databaseService.getApprovedUserCount();
|
||||
const role = isSeedAdmin
|
||||
? 'administrator'
|
||||
: approvedUserCount === 0
|
||||
? 'administrator'
|
||||
: 'coordinator';
|
||||
|
||||
user = await databaseService.createUser({
|
||||
id: auth0Id,
|
||||
google_id: auth0Id,
|
||||
email: profile.email,
|
||||
name: displayName,
|
||||
profile_picture_url: picture,
|
||||
role
|
||||
});
|
||||
|
||||
if (role === 'administrator') {
|
||||
user = await databaseService.updateUserApprovalStatus(profile.email, 'approved');
|
||||
}
|
||||
} else {
|
||||
const updated = await databaseService.updateUserLastSignIn(user.email);
|
||||
user = updated || user;
|
||||
|
||||
if (isSeedAdmin && user.role !== 'administrator') {
|
||||
user = await databaseService.updateUserRole(user.email, 'administrator');
|
||||
}
|
||||
if (isSeedAdmin && user.approval_status !== 'approved') {
|
||||
user = await databaseService.updateUserApprovalStatus(user.email, 'approved');
|
||||
}
|
||||
}
|
||||
|
||||
return { user, profile };
|
||||
}
|
||||
|
||||
export async function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const claims = await verifyAccessToken(token);
|
||||
const { user, profile } = await syncUserWithDatabase(claims, token);
|
||||
|
||||
req.auth = { token, claims, profile };
|
||||
req.user = user;
|
||||
|
||||
if (user.approval_status !== 'approved') {
|
||||
return res.status(403).json({
|
||||
error: 'pending_approval',
|
||||
message: 'Your account is pending administrator approval.',
|
||||
user: mapUserForResponse(user)
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error: any) {
|
||||
console.error('Auth0 token verification failed:', error);
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireRole(roles: string[]) {
|
||||
return (req: AuthedRequest, res: Response, next: NextFunction) => {
|
||||
const user = req.user;
|
||||
|
||||
if (!user || !roles.includes(user.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/setup', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const userCount = await databaseService.getUserCount();
|
||||
res.json({
|
||||
setupCompleted: isAuth0Configured(),
|
||||
firstAdminCreated: userCount > 0,
|
||||
oauthConfigured: isAuth0Configured(),
|
||||
authProvider: 'auth0'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking setup status:', error);
|
||||
res.status(500).json({ error: 'Database connection error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||
res.json({
|
||||
user: mapUserForResponse(req.user),
|
||||
auth0: {
|
||||
sub: req.auth?.claims.sub,
|
||||
scope: req.auth?.claims.scope
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/logout', (_req: Request, res: Response) => {
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
router.get('/status', requireAuth, (req: AuthedRequest, res: Response) => {
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: mapUserForResponse(req.user)
|
||||
});
|
||||
});
|
||||
|
||||
// USER MANAGEMENT ENDPOINTS
|
||||
|
||||
// List all users (admin only)
|
||||
router.get('/users', requireAuth, requireRole(['administrator']), async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const users = await databaseService.getAllUsers();
|
||||
|
||||
res.json(users.map(mapUserForResponse));
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch users' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update user role (admin only)
|
||||
router.patch('/users/:email/role', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
const { email } = req.params;
|
||||
const { role } = req.body;
|
||||
|
||||
if (!['administrator', 'coordinator', 'driver'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await databaseService.updateUserRole(email, role);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: mapUserForResponse(user)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user role:', error);
|
||||
res.status(500).json({ error: 'Failed to update user role' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete user (admin only)
|
||||
router.delete('/users/:email', requireAuth, requireRole(['administrator']), async (req: AuthedRequest, res: Response) => {
|
||||
const { email } = req.params;
|
||||
const currentUser = req.user;
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (email === currentUser.email) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
try {
|
||||
const deletedUser = await databaseService.deleteUser(email);
|
||||
|
||||
if (!deletedUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'User deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user by email (admin only)
|
||||
router.get('/users/:email', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
const { email } = req.params;
|
||||
|
||||
try {
|
||||
const user = await databaseService.getUserByEmail(email);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(mapUserForResponse(user));
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user' });
|
||||
}
|
||||
});
|
||||
|
||||
// USER APPROVAL ENDPOINTS
|
||||
|
||||
// Get pending users (admin only)
|
||||
router.get('/users/pending/list', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const pendingUsers = await databaseService.getPendingUsers();
|
||||
|
||||
const userList = pendingUsers.map(mapUserForResponse);
|
||||
|
||||
res.json(userList);
|
||||
} catch (error) {
|
||||
console.error('Error fetching pending users:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch pending users' });
|
||||
}
|
||||
});
|
||||
|
||||
// Approve or deny user (admin only)
|
||||
router.patch('/users/:email/approval', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
const { email } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!['approved', 'denied'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid approval status. Must be "approved" or "denied"' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await databaseService.updateUserApprovalStatus(email, status);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${status} successfully`,
|
||||
user: mapUserForResponse(user)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user approval:', error);
|
||||
res.status(500).json({ error: 'Failed to update user approval' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
306
backend/src/services/dataService.ts
Normal file
306
backend/src/services/dataService.ts
Normal 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();
|
||||
514
backend/src/services/databaseService.ts
Normal file
514
backend/src/services/databaseService.ts
Normal 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();
|
||||
184
backend/src/services/driverConflictService.ts
Normal file
184
backend/src/services/driverConflictService.ts
Normal 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 };
|
||||
679
backend/src/services/enhancedDataService.ts
Normal file
679
backend/src/services/enhancedDataService.ts
Normal 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();
|
||||
262
backend/src/services/flightService.ts
Normal file
262
backend/src/services/flightService.ts
Normal 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 };
|
||||
284
backend/src/services/flightTrackingScheduler.ts
Normal file
284
backend/src/services/flightTrackingScheduler.ts
Normal 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;
|
||||
248
backend/src/services/scheduleValidationService.ts
Normal file
248
backend/src/services/scheduleValidationService.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user