Backup: 2025-06-07 19:48 - Script test

[Restore from backup: vip-coordinator-backup-2025-06-07-19-48-script-test]
This commit is contained in:
2025-06-07 19:48:00 +02:00
parent 8fb00ec041
commit dc4655cef4
103 changed files with 16396 additions and 6143 deletions

57
backend/src/config/env.ts Normal file
View File

@@ -0,0 +1,57 @@
import { z } from 'zod';
import * as dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Define the environment schema
const envSchema = z.object({
// Database
DATABASE_URL: z.string().url().describe('PostgreSQL connection string'),
// Redis
REDIS_URL: z.string().url().describe('Redis connection string'),
// Google OAuth
GOOGLE_CLIENT_ID: z.string().min(1).describe('Google OAuth Client ID'),
GOOGLE_CLIENT_SECRET: z.string().min(1).describe('Google OAuth Client Secret'),
GOOGLE_REDIRECT_URI: z.string().url().describe('Google OAuth redirect URI'),
// Application
FRONTEND_URL: z.string().url().describe('Frontend application URL'),
JWT_SECRET: z.string().min(32).describe('JWT signing secret (min 32 chars)'),
// Server
PORT: z.string().transform(Number).default('3000'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});
// Validate and export environment variables
export const env = (() => {
try {
return envSchema.parse(process.env);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('❌ Invalid environment variables:');
console.error(error.format());
const missingVars = error.errors
.filter(err => err.code === 'invalid_type' && err.received === 'undefined')
.map(err => err.path.join('.'));
if (missingVars.length > 0) {
console.error('\n📋 Missing required environment variables:');
missingVars.forEach(varName => {
console.error(` - ${varName}`);
});
console.error('\n💡 Create a .env file based on .env.example');
}
process.exit(1);
}
throw error;
}
})();
// Type-safe environment variables
export type Env = z.infer<typeof envSchema>;

View File

@@ -0,0 +1,177 @@
// Mock database for when PostgreSQL is not available
interface MockUser {
id: string;
email: string;
name: string;
role: string;
google_id?: string;
created_at: Date;
updated_at: Date;
}
interface MockVIP {
id: string;
name: string;
organization?: string;
department: string;
transport_mode: string;
expected_arrival?: string;
needs_airport_pickup: boolean;
needs_venue_transport: boolean;
notes?: string;
created_at: Date;
updated_at: Date;
}
class MockDatabase {
private users: Map<string, MockUser> = new Map();
private vips: Map<string, MockVIP> = new Map();
private drivers: Map<string, any> = new Map();
private scheduleEvents: Map<string, any> = new Map();
private adminSettings: Map<string, string> = new Map();
constructor() {
// Add a test admin user
const adminId = '1';
this.users.set(adminId, {
id: adminId,
email: 'admin@example.com',
name: 'Test Admin',
role: 'admin',
created_at: new Date(),
updated_at: new Date()
});
// Add some test VIPs
this.vips.set('1', {
id: '1',
name: 'John Doe',
organization: 'Test Org',
department: 'Office of Development',
transport_mode: 'flight',
expected_arrival: '2025-07-25 14:00',
needs_airport_pickup: true,
needs_venue_transport: true,
notes: 'Test VIP',
created_at: new Date(),
updated_at: new Date()
});
}
async query(text: string, params?: any[]): Promise<any> {
console.log('Mock DB Query:', text.substring(0, 50) + '...');
// Handle user queries
if (text.includes('COUNT(*) FROM users')) {
return { rows: [{ count: this.users.size.toString() }] };
}
if (text.includes('SELECT * FROM users WHERE email')) {
const email = params?.[0];
const user = Array.from(this.users.values()).find(u => u.email === email);
return { rows: user ? [user] : [] };
}
if (text.includes('SELECT * FROM users WHERE id')) {
const id = params?.[0];
const user = this.users.get(id);
return { rows: user ? [user] : [] };
}
if (text.includes('SELECT * FROM users WHERE google_id')) {
const google_id = params?.[0];
const user = Array.from(this.users.values()).find(u => u.google_id === google_id);
return { rows: user ? [user] : [] };
}
if (text.includes('INSERT INTO users')) {
const id = Date.now().toString();
const user: MockUser = {
id,
email: params?.[0],
name: params?.[1],
role: params?.[2] || 'coordinator',
google_id: params?.[4],
created_at: new Date(),
updated_at: new Date()
};
this.users.set(id, user);
return { rows: [user] };
}
// Handle VIP queries
if (text.includes('SELECT v.*') && text.includes('FROM vips')) {
const vips = Array.from(this.vips.values());
return {
rows: vips.map(v => ({
...v,
flights: []
}))
};
}
// Handle admin settings queries
if (text.includes('SELECT * FROM admin_settings')) {
const settings = Array.from(this.adminSettings.entries()).map(([key, value]) => ({
key,
value
}));
return { rows: settings };
}
// Handle drivers queries
if (text.includes('SELECT * FROM drivers')) {
const drivers = Array.from(this.drivers.values());
return { rows: drivers };
}
// Handle schedule events queries
if (text.includes('SELECT * FROM schedule_events')) {
const events = Array.from(this.scheduleEvents.values());
return { rows: events };
}
if (text.includes('INSERT INTO vips')) {
const id = Date.now().toString();
const vip: MockVIP = {
id,
name: params?.[0],
organization: params?.[1],
department: params?.[2] || 'Office of Development',
transport_mode: params?.[3] || 'flight',
expected_arrival: params?.[4],
needs_airport_pickup: params?.[5] !== false,
needs_venue_transport: params?.[6] !== false,
notes: params?.[7] || '',
created_at: new Date(),
updated_at: new Date()
};
this.vips.set(id, vip);
return { rows: [vip] };
}
// Default empty result
console.log('Unhandled query:', text);
return { rows: [] };
}
async connect() {
return {
query: this.query.bind(this),
release: () => {}
};
}
// Make compatible with pg Pool interface
async end() {
console.log('Mock database connection closed');
}
on(event: string, callback: Function) {
if (event === 'connect') {
setTimeout(() => callback(), 100);
}
}
}
export default MockDatabase;

View File

@@ -32,10 +32,22 @@ export function getGoogleAuthUrl(): string {
const clientId = process.env.GOOGLE_CLIENT_ID;
const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/auth/google/callback';
console.log('🔗 Generating Google OAuth URL:', {
client_id_present: !!clientId,
redirect_uri: redirectUri,
environment: process.env.NODE_ENV || 'development'
});
if (!clientId) {
console.error('❌ GOOGLE_CLIENT_ID not configured');
throw new Error('GOOGLE_CLIENT_ID not configured');
}
if (!redirectUri.startsWith('http')) {
console.error('❌ Invalid redirect URI:', redirectUri);
throw new Error('GOOGLE_REDIRECT_URI must be a valid HTTP/HTTPS URL');
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
@@ -45,7 +57,10 @@ export function getGoogleAuthUrl(): string {
prompt: 'consent'
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
console.log('✅ Google OAuth URL generated successfully');
return authUrl;
}
// Exchange authorization code for tokens
@@ -54,48 +69,168 @@ export async function exchangeCodeForTokens(code: string): Promise<any> {
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/auth/google/callback';
console.log('🔄 Exchanging OAuth code for tokens:', {
client_id_present: !!clientId,
client_secret_present: !!clientSecret,
redirect_uri: redirectUri,
code_length: code?.length || 0
});
if (!clientId || !clientSecret) {
console.error('❌ Google OAuth credentials not configured:', {
client_id: !!clientId,
client_secret: !!clientSecret
});
throw new Error('Google OAuth credentials not configured');
}
if (!code || code.length < 10) {
console.error('❌ Invalid authorization code:', { code_length: code?.length || 0 });
throw new Error('Invalid authorization code provided');
}
try {
const response = await fetch('https://oauth2.googleapis.com/token', {
const tokenUrl = 'https://oauth2.googleapis.com/token';
const requestBody = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
});
console.log('📡 Making token exchange request to Google:', {
url: tokenUrl,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
});
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
}),
body: requestBody,
});
const responseText = await response.text();
console.log('📨 Token exchange response:', {
status: response.status,
ok: response.ok,
content_type: response.headers.get('content-type'),
response_length: responseText.length
});
if (!response.ok) {
throw new Error('Failed to exchange code for tokens');
console.error('❌ Token exchange failed:', {
status: response.status,
statusText: response.statusText,
response: responseText
});
throw new Error(`Failed to exchange code for tokens: ${response.status} ${response.statusText}`);
}
return await response.json();
let tokenData;
try {
tokenData = JSON.parse(responseText);
} catch (parseError) {
console.error('❌ Failed to parse token response:', { response: responseText });
throw new Error('Invalid JSON response from Google token endpoint');
}
if (!tokenData.access_token) {
console.error('❌ No access token in response:', tokenData);
throw new Error('No access token received from Google');
}
console.log('✅ Token exchange successful:', {
has_access_token: !!tokenData.access_token,
has_refresh_token: !!tokenData.refresh_token,
token_type: tokenData.token_type,
expires_in: tokenData.expires_in
});
return tokenData;
} catch (error) {
console.error('Error exchanging code for tokens:', error);
console.error('Error exchanging code for tokens:', {
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
});
throw error;
}
}
// Get user info from Google
export async function getGoogleUserInfo(accessToken: string): Promise<any> {
console.log('👤 Getting user info from Google:', {
token_length: accessToken?.length || 0,
token_prefix: accessToken ? accessToken.substring(0, 10) + '...' : 'none'
});
if (!accessToken || accessToken.length < 10) {
console.error('❌ Invalid access token for user info request');
throw new Error('Invalid access token provided');
}
try {
const response = await fetch(`https://www.googleapis.com/oauth2/v2/userinfo?access_token=${accessToken}`);
const userInfoUrl = `https://www.googleapis.com/oauth2/v2/userinfo?access_token=${accessToken}`;
console.log('📡 Making user info request to Google');
const response = await fetch(userInfoUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`
}
});
const responseText = await response.text();
console.log('📨 User info response:', {
status: response.status,
ok: response.ok,
content_type: response.headers.get('content-type'),
response_length: responseText.length
});
if (!response.ok) {
throw new Error('Failed to get user info');
console.error('Failed to get user info:', {
status: response.status,
statusText: response.statusText,
response: responseText
});
throw new Error(`Failed to get user info: ${response.status} ${response.statusText}`);
}
return await response.json();
let userData;
try {
userData = JSON.parse(responseText);
} catch (parseError) {
console.error('❌ Failed to parse user info response:', { response: responseText });
throw new Error('Invalid JSON response from Google user info endpoint');
}
if (!userData.email) {
console.error('❌ No email in user info response:', userData);
throw new Error('No email address received from Google');
}
console.log('✅ User info retrieved successfully:', {
email: userData.email,
name: userData.name,
verified_email: userData.verified_email,
has_picture: !!userData.picture
});
return userData;
} catch (error) {
console.error('Error getting Google user info:', error);
console.error('Error getting Google user info:', {
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
});
throw error;
}
}

View File

@@ -0,0 +1,878 @@
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';
import jwtKeyManager from './services/jwtKeyManager'; // Initialize JWT Key Manager
import { errorHandler, notFoundHandler, asyncHandler } from './middleware/errorHandler';
import { requestLogger, errorLogger } from './middleware/logger';
import { AppError, NotFoundError, ValidationError } from './types/errors';
import { validate, validateQuery, validateParams } from './middleware/validation';
import {
createVipSchema,
updateVipSchema,
createDriverSchema,
updateDriverSchema,
createScheduleEventSchema,
updateScheduleEventSchema,
paginationSchema
} from './types/schemas';
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',
'http://localhost:5173',
'http://localhost:3000',
'http://localhost', // Frontend Docker container (local testing)
'https://bsa.madeamess.online' // Production frontend domain (where users access the site)
],
credentials: true
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Add request logging
app.use(requestLogger);
// 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'));
// Enhanced health check endpoint with authentication system status
app.get('/api/health', asyncHandler(async (req: Request, res: Response) => {
const timestamp = new Date().toISOString();
// Check JWT Key Manager status
const jwtStatus = jwtKeyManager.getStatus();
// Check environment variables
const envCheck = {
google_client_id: !!process.env.GOOGLE_CLIENT_ID,
google_client_secret: !!process.env.GOOGLE_CLIENT_SECRET,
google_redirect_uri: !!process.env.GOOGLE_REDIRECT_URI,
frontend_url: !!process.env.FRONTEND_URL,
database_url: !!process.env.DATABASE_URL,
admin_password: !!process.env.ADMIN_PASSWORD
};
// Check database connectivity
let databaseStatus = 'unknown';
let userCount = 0;
try {
userCount = await databaseService.getUserCount();
databaseStatus = 'connected';
} catch (dbError) {
databaseStatus = 'disconnected';
console.error('Health check - Database error:', dbError);
}
// Overall system health
const isHealthy = databaseStatus === 'connected' &&
jwtStatus.hasCurrentKey &&
envCheck.google_client_id &&
envCheck.google_client_secret;
const healthData = {
status: isHealthy ? 'OK' : 'DEGRADED',
timestamp,
version: '1.0.0',
environment: process.env.NODE_ENV || 'development',
services: {
database: {
status: databaseStatus,
user_count: databaseStatus === 'connected' ? userCount : null
},
authentication: {
jwt_key_manager: jwtStatus,
oauth_configured: envCheck.google_client_id && envCheck.google_client_secret,
environment_variables: envCheck
}
},
uptime: process.uptime(),
memory: process.memoryUsage()
};
// Log health check for monitoring
console.log(`🏥 Health Check [${timestamp}]:`, {
status: healthData.status,
database: databaseStatus,
jwt_keys: jwtStatus.hasCurrentKey,
oauth: envCheck.google_client_id && envCheck.google_client_secret
});
res.status(isHealthy ? 200 : 503).json(healthData);
}));
// Data is now persisted using dataService - no more in-memory storage!
// Admin password - MUST be set via environment variable in production
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'CHANGE_ME_ADMIN_PASSWORD';
// Initialize flight tracking scheduler
const flightTracker = new FlightTrackingScheduler(flightService);
// VIP routes (protected)
app.post('/api/vips', requireAuth, requireRole(['coordinator', 'administrator']), validate(createVipSchema), asyncHandler(async (req: Request, res: Response) => {
// Create a new VIP - data is already validated
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']), validate(updateVipSchema), asyncHandler(async (req: Request, res: Response) => {
// Update a VIP - data is already validated
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;
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);
}));
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']), validate(createDriverSchema), asyncHandler(async (req: Request, res: Response) => {
// Create a new driver - data is already validated
const { name, phone, email, vehicleInfo, status } = req.body;
const newDriver = {
id: Date.now().toString(),
name,
phone,
email,
vehicleInfo,
status: status || 'available',
department: 'Office of Development', // Default to Office of Development
currentLocation: { lat: 0, lng: 0 },
assignedVipIds: []
};
const savedDriver = await enhancedDataService.addDriver(newDriver);
res.status(201).json(savedDriver);
}));
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) => 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) => 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]) => {
events.forEach((event) => {
if (event.assignedDriverId === driverId) {
// Get VIP name
const vip = vips.find((v) => 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.post('/api/admin/authenticate', (req: Request, res: Response) => {
const { password } = req.body;
if (password === ADMIN_PASSWORD) {
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid password' });
}
});
app.get('/api/admin/settings', async (req: Request, res: Response) => {
const adminAuth = req.headers['admin-auth'];
if (adminAuth !== 'true') {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const adminSettings = await enhancedDataService.getAdminSettings();
// Return settings but mask API keys for display only
// IMPORTANT: Don't return the actual keys, just indicate they exist
const maskedSettings = {
apiKeys: {
aviationStackKey: adminSettings.apiKeys.aviationStackKey ? '***' + adminSettings.apiKeys.aviationStackKey.slice(-4) : '',
googleMapsKey: adminSettings.apiKeys.googleMapsKey ? '***' + adminSettings.apiKeys.googleMapsKey.slice(-4) : '',
twilioKey: adminSettings.apiKeys.twilioKey ? '***' + adminSettings.apiKeys.twilioKey.slice(-4) : '',
googleClientId: adminSettings.apiKeys.googleClientId ? '***' + adminSettings.apiKeys.googleClientId.slice(-4) : '',
googleClientSecret: adminSettings.apiKeys.googleClientSecret ? '***' + adminSettings.apiKeys.googleClientSecret.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', async (req: Request, res: Response) => {
const adminAuth = req.headers['admin-auth'];
if (adminAuth !== 'true') {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const { apiKeys, systemSettings } = req.body;
const currentSettings = await enhancedDataService.getAdminSettings();
// 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.googleClientId && !apiKeys.googleClientId.startsWith('***')) {
currentSettings.apiKeys.googleClientId = apiKeys.googleClientId;
// Update the environment variable for Google OAuth
process.env.GOOGLE_CLIENT_ID = apiKeys.googleClientId;
}
if (apiKeys.googleClientSecret && !apiKeys.googleClientSecret.startsWith('***')) {
currentSettings.apiKeys.googleClientSecret = apiKeys.googleClientSecret;
// Update the environment variable for Google OAuth
process.env.GOOGLE_CLIENT_SECRET = apiKeys.googleClientSecret;
}
}
// 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', async (req: Request, res: Response) => {
const adminAuth = req.headers['admin-auth'];
if (adminAuth !== 'true') {
return res.status(401).json({ error: 'Unauthorized' });
}
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 = 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' });
}
});
// JWT Key Management endpoints (admin only)
app.get('/api/admin/jwt-status', requireAuth, requireRole(['administrator']), (req: Request, res: Response) => {
const jwtKeyManager = require('./services/jwtKeyManager').default;
const status = jwtKeyManager.getStatus();
res.json({
keyRotationEnabled: true,
rotationInterval: '24 hours',
gracePeriod: '24 hours',
...status,
message: 'JWT keys are automatically rotated every 24 hours for enhanced security'
});
});
app.post('/api/admin/jwt-rotate', requireAuth, requireRole(['administrator']), (req: Request, res: Response) => {
const jwtKeyManager = require('./services/jwtKeyManager').default;
try {
jwtKeyManager.forceRotation();
res.json({
success: true,
message: 'JWT key rotation triggered successfully. New tokens will use the new key.'
});
} catch (error) {
res.status(500).json({ error: 'Failed to rotate JWT keys' });
}
});
// Initialize database and start server
// Add 404 handler for undefined routes
app.use(notFoundHandler);
// Add error logging middleware
app.use(errorLogger);
// Add global error handler (must be last!)
app.use(errorHandler);
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 password: ${ADMIN_PASSWORD}`);
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();

View File

@@ -19,10 +19,10 @@ const port: number = process.env.PORT ? parseInt(process.env.PORT) : 3000;
app.use(cors({
origin: [
process.env.FRONTEND_URL || 'http://localhost:5173',
'https://bsa.madeamess.online:5173',
'https://bsa.madeamess.online',
'https://api.bsa.madeamess.online',
'http://bsa.madeamess.online:5173'
'http://localhost:5173',
'http://localhost:3000',
'http://localhost', // Frontend Docker container (local testing)
'https://bsa.madeamess.online' // Production frontend domain (where users access the site)
],
credentials: true
}));
@@ -42,15 +42,85 @@ app.get('/admin-bypass', (req: Request, res: Response) => {
// 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() });
// Enhanced health check endpoint with authentication system status
app.get('/api/health', async (req: Request, res: Response) => {
try {
const timestamp = new Date().toISOString();
// Check JWT Key Manager status
const jwtStatus = jwtKeyManager.getStatus();
// Check environment variables
const envCheck = {
google_client_id: !!process.env.GOOGLE_CLIENT_ID,
google_client_secret: !!process.env.GOOGLE_CLIENT_SECRET,
google_redirect_uri: !!process.env.GOOGLE_REDIRECT_URI,
frontend_url: !!process.env.FRONTEND_URL,
database_url: !!process.env.DATABASE_URL,
admin_password: !!process.env.ADMIN_PASSWORD
};
// Check database connectivity
let databaseStatus = 'unknown';
let userCount = 0;
try {
userCount = await databaseService.getUserCount();
databaseStatus = 'connected';
} catch (dbError) {
databaseStatus = 'disconnected';
console.error('Health check - Database error:', dbError);
}
// Overall system health
const isHealthy = databaseStatus === 'connected' &&
jwtStatus.hasCurrentKey &&
envCheck.google_client_id &&
envCheck.google_client_secret;
const healthData = {
status: isHealthy ? 'OK' : 'DEGRADED',
timestamp,
version: '1.0.0',
environment: process.env.NODE_ENV || 'development',
services: {
database: {
status: databaseStatus,
user_count: databaseStatus === 'connected' ? userCount : null
},
authentication: {
jwt_key_manager: jwtStatus,
oauth_configured: envCheck.google_client_id && envCheck.google_client_secret,
environment_variables: envCheck
}
},
uptime: process.uptime(),
memory: process.memoryUsage()
};
// Log health check for monitoring
console.log(`🏥 Health Check [${timestamp}]:`, {
status: healthData.status,
database: databaseStatus,
jwt_keys: jwtStatus.hasCurrentKey,
oauth: envCheck.google_client_id && envCheck.google_client_secret
});
res.status(isHealthy ? 200 : 503).json(healthData);
} catch (error) {
console.error('Health check error:', error);
res.status(500).json({
status: 'ERROR',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Data is now persisted using dataService - no more in-memory storage!
// Simple admin password (in production, use proper auth)
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
// Admin password - MUST be set via environment variable in production
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'CHANGE_ME_ADMIN_PASSWORD';
// Initialize flight tracking scheduler
const flightTracker = new FlightTrackingScheduler(flightService);

View File

@@ -0,0 +1,263 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authService from './services/authService';
import dataService from './services/unifiedDataService';
import { validate, schemas } from './middleware/simpleValidation';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
// Middleware
app.use(cors({
origin: [
process.env.FRONTEND_URL || 'http://localhost:5173',
'https://bsa.madeamess.online'
],
credentials: true
}));
app.use(express.json());
app.use(express.static('public'));
// Health check
app.get('/api/health', (req, res) => {
res.json({
status: 'OK',
timestamp: new Date().toISOString(),
version: '2.0.0' // Simplified version
});
});
// Auth routes
app.get('/auth/google', (req, res) => {
res.redirect(authService.getGoogleAuthUrl());
});
app.post('/auth/google/callback', async (req, res) => {
try {
const { code } = req.body;
const { user, token } = await authService.handleGoogleAuth(code);
res.json({ user, token });
} catch (error) {
res.status(400).json({ error: 'Authentication failed' });
}
});
app.get('/auth/me', authService.requireAuth, (req: any, res) => {
res.json(req.user);
});
app.post('/auth/logout', (req, res) => {
res.json({ message: 'Logged out successfully' });
});
// VIP routes
app.get('/api/vips', authService.requireAuth, async (req, res, next) => {
try {
const vips = await dataService.getVips();
res.json(vips);
} catch (error) {
next(error);
}
});
app.get('/api/vips/:id', authService.requireAuth, async (req, res, next) => {
try {
const vip = await dataService.getVipById(req.params.id);
if (!vip) return res.status(404).json({ error: 'VIP not found' });
res.json(vip);
} catch (error) {
next(error);
}
});
app.post('/api/vips',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
validate(schemas.createVip),
async (req, res, next) => {
try {
const vip = await dataService.createVip(req.body);
res.status(201).json(vip);
} catch (error) {
next(error);
}
}
);
app.put('/api/vips/:id',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
validate(schemas.updateVip),
async (req, res, next) => {
try {
const vip = await dataService.updateVip(req.params.id, req.body);
if (!vip) return res.status(404).json({ error: 'VIP not found' });
res.json(vip);
} catch (error) {
next(error);
}
}
);
app.delete('/api/vips/:id',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
async (req, res, next) => {
try {
const vip = await dataService.deleteVip(req.params.id);
if (!vip) return res.status(404).json({ error: 'VIP not found' });
res.json({ message: 'VIP deleted successfully' });
} catch (error) {
next(error);
}
}
);
// Driver routes
app.get('/api/drivers', authService.requireAuth, async (req, res, next) => {
try {
const drivers = await dataService.getDrivers();
res.json(drivers);
} catch (error) {
next(error);
}
});
app.post('/api/drivers',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
validate(schemas.createDriver),
async (req, res, next) => {
try {
const driver = await dataService.createDriver(req.body);
res.status(201).json(driver);
} catch (error) {
next(error);
}
}
);
app.put('/api/drivers/:id',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
validate(schemas.updateDriver),
async (req, res, next) => {
try {
const driver = await dataService.updateDriver(req.params.id, req.body);
if (!driver) return res.status(404).json({ error: 'Driver not found' });
res.json(driver);
} catch (error) {
next(error);
}
}
);
app.delete('/api/drivers/:id',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
async (req, res, next) => {
try {
const driver = await dataService.deleteDriver(req.params.id);
if (!driver) return res.status(404).json({ error: 'Driver not found' });
res.json({ message: 'Driver deleted successfully' });
} catch (error) {
next(error);
}
}
);
// Schedule routes
app.get('/api/vips/:vipId/schedule', authService.requireAuth, async (req, res, next) => {
try {
const schedule = await dataService.getScheduleByVipId(req.params.vipId);
res.json(schedule);
} catch (error) {
next(error);
}
});
app.post('/api/vips/:vipId/schedule',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
validate(schemas.createScheduleEvent),
async (req, res, next) => {
try {
const event = await dataService.createScheduleEvent(req.params.vipId, req.body);
res.status(201).json(event);
} catch (error) {
next(error);
}
}
);
app.put('/api/vips/:vipId/schedule/:eventId',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
validate(schemas.updateScheduleEvent),
async (req, res, next) => {
try {
const event = await dataService.updateScheduleEvent(req.params.eventId, req.body);
if (!event) return res.status(404).json({ error: 'Event not found' });
res.json(event);
} catch (error) {
next(error);
}
}
);
app.delete('/api/vips/:vipId/schedule/:eventId',
authService.requireAuth,
authService.requireRole(['coordinator', 'administrator']),
async (req, res, next) => {
try {
const event = await dataService.deleteScheduleEvent(req.params.eventId);
if (!event) return res.status(404).json({ error: 'Event not found' });
res.json({ message: 'Event deleted successfully' });
} catch (error) {
next(error);
}
}
);
// Admin routes (simplified)
app.get('/api/admin/settings',
authService.requireAuth,
authService.requireRole(['administrator']),
async (req, res, next) => {
try {
const settings = await dataService.getAdminSettings();
res.json(settings);
} catch (error) {
next(error);
}
}
);
app.post('/api/admin/settings',
authService.requireAuth,
authService.requireRole(['administrator']),
async (req, res, next) => {
try {
const { key, value } = req.body;
await dataService.updateAdminSetting(key, value);
res.json({ message: 'Setting updated successfully' });
} catch (error) {
next(error);
}
}
);
// Error handling
app.use(notFoundHandler);
app.use(errorHandler);
// Start server
app.listen(port, () => {
console.log(`🚀 Server running on port ${port}`);
console.log(`🏥 Health check: http://localhost:${port}/api/health`);
console.log(`📚 API docs: http://localhost:${port}/api-docs.html`);
});

View File

@@ -0,0 +1,78 @@
import { Request, Response, NextFunction } from 'express';
import { AppError, ErrorResponse } from '../types/errors';
export const errorHandler = (
err: Error | AppError,
req: Request,
res: Response,
next: NextFunction
): void => {
// Default error values
let statusCode = 500;
let message = 'Internal server error';
let isOperational = false;
// If it's an AppError, use its properties
if (err instanceof AppError) {
statusCode = err.statusCode;
message = err.message;
isOperational = err.isOperational;
} else if (err.name === 'ValidationError') {
// Handle validation errors (e.g., from libraries)
statusCode = 400;
message = err.message;
isOperational = true;
} else if (err.name === 'JsonWebTokenError') {
statusCode = 401;
message = 'Invalid token';
isOperational = true;
} else if (err.name === 'TokenExpiredError') {
statusCode = 401;
message = 'Token expired';
isOperational = true;
}
// Log error details (in production, use proper logging service)
if (!isOperational) {
console.error('ERROR 💥:', err);
} else {
console.error(`Operational error: ${message}`);
}
// Create error response
const errorResponse: ErrorResponse = {
success: false,
error: {
message,
...(process.env.NODE_ENV === 'development' && {
details: err.stack
})
},
timestamp: new Date().toISOString(),
path: req.path
};
res.status(statusCode).json(errorResponse);
};
// Async error wrapper to catch errors in async route handlers
export const asyncHandler = (fn: Function) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// 404 Not Found handler
export const notFoundHandler = (req: Request, res: Response): void => {
const errorResponse: ErrorResponse = {
success: false,
error: {
message: `Route ${req.originalUrl} not found`,
code: 'ROUTE_NOT_FOUND'
},
timestamp: new Date().toISOString(),
path: req.path
};
res.status(404).json(errorResponse);
};

View File

@@ -0,0 +1,88 @@
import { Request, Response, NextFunction } from 'express';
import { AuthRequest } from '../types/api';
interface LogContext {
requestId: string;
method: string;
url: string;
ip: string;
userAgent?: string;
userId?: string;
}
// Extend Express Request with our custom properties
declare module 'express' {
interface Request {
requestId?: string;
user?: {
id: string;
email: string;
name: string;
role: string;
};
}
}
// Generate a simple request ID
const generateRequestId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
// Request logger middleware
export const requestLogger = (req: Request, res: Response, next: NextFunction): void => {
const requestId = generateRequestId();
// Attach request ID to request object
req.requestId = requestId;
const startTime = Date.now();
// Log request
const logContext: LogContext = {
requestId,
method: req.method,
url: req.originalUrl,
ip: req.ip || 'unknown',
userAgent: req.get('user-agent'),
userId: req.user?.id
};
console.log(`[${new Date().toISOString()}] REQUEST:`, JSON.stringify(logContext));
// Log response
const originalSend = res.send;
res.send = function(data: unknown): Response {
const duration = Date.now() - startTime;
console.log(`[${new Date().toISOString()}] RESPONSE:`, JSON.stringify({
requestId,
statusCode: res.statusCode,
duration: `${duration}ms`
}));
return originalSend.call(this, data);
};
next();
};
// Error logger (to be used before error handler)
export const errorLogger = (err: Error, req: Request, res: Response, next: NextFunction): void => {
const requestId = req.requestId || 'unknown';
console.error(`[${new Date().toISOString()}] ERROR:`, JSON.stringify({
requestId,
error: {
name: err.name,
message: err.message,
stack: err.stack
},
request: {
method: req.method,
url: req.originalUrl,
headers: req.headers,
body: req.body
}
}));
next(err);
};

View File

@@ -0,0 +1,93 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
// Simplified validation schemas - removed unnecessary complexity
export const schemas = {
// VIP schemas
createVip: z.object({
name: z.string().min(1).max(100),
organization: z.string().max(100).optional(),
department: z.enum(['Office of Development', 'Admin']).default('Office of Development'),
transportMode: z.enum(['flight', 'self-driving']).default('flight'),
flights: z.array(z.object({
flightNumber: z.string(),
airline: z.string().optional(),
scheduledArrival: z.string(),
scheduledDeparture: z.string().optional()
})).optional(),
expectedArrival: z.string().optional(),
needsAirportPickup: z.boolean().default(true),
needsVenueTransport: z.boolean().default(true),
notes: z.string().max(500).optional()
}),
updateVip: z.object({
name: z.string().min(1).max(100).optional(),
organization: z.string().max(100).optional(),
department: z.enum(['Office of Development', 'Admin']).optional(),
transportMode: z.enum(['flight', 'self-driving']).optional(),
flights: z.array(z.object({
flightNumber: z.string(),
airline: z.string().optional(),
scheduledArrival: z.string(),
scheduledDeparture: z.string().optional()
})).optional(),
expectedArrival: z.string().optional(),
needsAirportPickup: z.boolean().optional(),
needsVenueTransport: z.boolean().optional(),
notes: z.string().max(500).optional()
}),
// Driver schemas
createDriver: z.object({
name: z.string().min(1).max(100),
email: z.string().email().optional(),
phone: z.string(),
vehicleInfo: z.string().max(200).optional(),
status: z.enum(['available', 'assigned', 'unavailable']).default('available')
}),
updateDriver: z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
phone: z.string().optional(),
vehicleInfo: z.string().max(200).optional(),
status: z.enum(['available', 'assigned', 'unavailable']).optional()
}),
// Schedule schemas
createScheduleEvent: z.object({
driverId: z.string().optional(),
eventTime: z.string(),
eventType: z.enum(['pickup', 'dropoff', 'custom']),
location: z.string().min(1).max(200),
notes: z.string().max(500).optional()
}),
updateScheduleEvent: z.object({
driverId: z.string().optional(),
eventTime: z.string().optional(),
eventType: z.enum(['pickup', 'dropoff', 'custom']).optional(),
location: z.string().min(1).max(200).optional(),
notes: z.string().max(500).optional(),
status: z.enum(['scheduled', 'in_progress', 'completed', 'cancelled']).optional()
})
};
// Single validation middleware
export const validate = (schema: z.ZodSchema) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
req.body = await schema.parseAsync(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
const message = error.errors
.map(err => `${err.path.join('.')}: ${err.message}`)
.join(', ');
return res.status(400).json({ error: message });
}
next(error);
}
};
};

View File

@@ -0,0 +1,75 @@
import { Request, Response, NextFunction } from 'express';
import { z, ZodError } from 'zod';
import { ValidationError } from '../types/errors';
export const validate = (schema: z.ZodSchema) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// Validate request body
req.body = await schema.parseAsync(req.body);
next();
} catch (error) {
if (error instanceof ZodError) {
// Format Zod errors into a user-friendly message
const errors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
const message = errors.map(e => `${e.field}: ${e.message}`).join(', ');
next(new ValidationError(message));
} else {
next(error);
}
}
};
};
export const validateQuery = (schema: z.ZodSchema) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// Validate query parameters
req.query = await schema.parseAsync(req.query);
next();
} catch (error) {
if (error instanceof ZodError) {
// Format Zod errors into a user-friendly message
const errors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
const message = errors.map(e => `${e.field}: ${e.message}`).join(', ');
next(new ValidationError(`Invalid query parameters: ${message}`));
} else {
next(error);
}
}
};
};
export const validateParams = (schema: z.ZodSchema) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// Validate route parameters
req.params = await schema.parseAsync(req.params);
next();
} catch (error) {
if (error instanceof ZodError) {
// Format Zod errors into a user-friendly message
const errors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
const message = errors.map(e => `${e.field}: ${e.message}`).join(', ');
next(new ValidationError(`Invalid route parameters: ${message}`));
} else {
next(error);
}
}
};
};

View File

@@ -0,0 +1,114 @@
-- Migration: Add user management fields
-- Purpose: Support comprehensive user onboarding and approval system
-- 1. Add new columns to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'deactivated')),
ADD COLUMN IF NOT EXISTS phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS organization VARCHAR(255),
ADD COLUMN IF NOT EXISTS onboarding_data JSONB,
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(255),
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS rejected_by VARCHAR(255),
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS deactivated_by VARCHAR(255),
ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMP;
-- 2. Update existing users to have 'active' status if they were already approved
UPDATE users
SET status = 'active'
WHERE approval_status = 'approved' AND status IS NULL;
-- 3. Update role check constraint to include 'viewer' role
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_role_check;
ALTER TABLE users
ADD CONSTRAINT users_role_check
CHECK (role IN ('driver', 'coordinator', 'administrator', 'viewer'));
-- 4. Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_email_status ON users(email, status);
CREATE INDEX IF NOT EXISTS idx_users_organization ON users(organization);
-- 5. Create audit log table for user management actions
CREATE TABLE IF NOT EXISTS user_audit_log (
id SERIAL PRIMARY KEY,
action VARCHAR(50) NOT NULL,
user_email VARCHAR(255) NOT NULL,
performed_by VARCHAR(255) NOT NULL,
action_details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 6. Create index on audit log
CREATE INDEX IF NOT EXISTS idx_user_audit_log_user_email ON user_audit_log(user_email);
CREATE INDEX IF NOT EXISTS idx_user_audit_log_performed_by ON user_audit_log(performed_by);
CREATE INDEX IF NOT EXISTS idx_user_audit_log_created_at ON user_audit_log(created_at DESC);
-- 7. Fix first user to be administrator
-- Update the first created user to be an administrator if they're not already
UPDATE users
SET role = 'administrator',
status = 'active',
approval_status = 'approved'
WHERE created_at = (SELECT MIN(created_at) FROM users)
AND role != 'administrator';
-- 8. Add comment to document the schema
COMMENT ON COLUMN users.status IS 'User account status: pending (awaiting approval), active (approved and can log in), deactivated (account disabled)';
COMMENT ON COLUMN users.onboarding_data IS 'JSON data collected during onboarding. For drivers: vehicleType, vehicleCapacity, licensePlate, homeLocation, requestedRole, reason';
COMMENT ON COLUMN users.approved_by IS 'Email of the administrator who approved this user';
COMMENT ON COLUMN users.approved_at IS 'Timestamp when the user was approved';
-- 9. Create a function to handle user approval with audit logging
CREATE OR REPLACE FUNCTION approve_user(
p_user_email VARCHAR,
p_approved_by VARCHAR,
p_new_role VARCHAR DEFAULT NULL
)
RETURNS VOID AS $$
BEGIN
-- Update user status
UPDATE users
SET status = 'active',
approval_status = 'approved',
approved_by = p_approved_by,
approved_at = CURRENT_TIMESTAMP,
role = COALESCE(p_new_role, role),
updated_at = CURRENT_TIMESTAMP
WHERE email = p_user_email;
-- Log the action
INSERT INTO user_audit_log (action, user_email, performed_by, action_details)
VALUES ('user_approved', p_user_email, p_approved_by,
jsonb_build_object('new_role', COALESCE(p_new_role, (SELECT role FROM users WHERE email = p_user_email))));
END;
$$ LANGUAGE plpgsql;
-- 10. Create a function to handle user rejection with audit logging
CREATE OR REPLACE FUNCTION reject_user(
p_user_email VARCHAR,
p_rejected_by VARCHAR,
p_reason VARCHAR DEFAULT NULL
)
RETURNS VOID AS $$
BEGIN
-- Update user status
UPDATE users
SET status = 'deactivated',
approval_status = 'denied',
rejected_by = p_rejected_by,
rejected_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE email = p_user_email;
-- Log the action
INSERT INTO user_audit_log (action, user_email, performed_by, action_details)
VALUES ('user_rejected', p_user_email, p_rejected_by,
jsonb_build_object('reason', p_reason));
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,309 @@
import request from 'supertest';
import express from 'express';
import { testPool } from '../../tests/setup';
import {
testUsers,
testVips,
testFlights,
insertTestUser,
insertTestVip,
createTestJwtPayload
} from '../../tests/fixtures';
import jwt from 'jsonwebtoken';
// Mock JWT signing
jest.mock('jsonwebtoken');
describe('VIPs API Endpoints', () => {
let app: express.Application;
let authToken: string;
beforeEach(async () => {
// Create a minimal Express app for testing
app = express();
app.use(express.json());
// Mock authentication middleware
app.use((req, res, next) => {
if (req.headers.authorization) {
const token = req.headers.authorization.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, 'test-secret');
(req as any).user = decoded;
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
next();
});
// TODO: Mount actual VIP routes here
// app.use('/api/vips', vipRoutes);
// For now, create mock routes
app.get('/api/vips', async (req, res) => {
try {
const result = await testPool.query('SELECT * FROM vips ORDER BY arrival_datetime');
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});
app.post('/api/vips', async (req, res) => {
if (!(req as any).user || (req as any).user.role !== 'administrator') {
return res.status(403).json({ error: 'Forbidden' });
}
try {
const { name, title, organization, arrival_datetime } = req.body;
const result = await testPool.query(
`INSERT INTO vips (id, name, title, organization, arrival_datetime, status, created_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'scheduled', NOW())
RETURNING *`,
[name, title, organization, arrival_datetime]
);
res.status(201).json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});
app.get('/api/vips/:id', async (req, res) => {
try {
const result = await testPool.query('SELECT * FROM vips WHERE id = $1', [req.params.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'VIP not found' });
}
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});
app.put('/api/vips/:id', async (req, res) => {
if (!(req as any).user) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const { name, title, status } = req.body;
const result = await testPool.query(
`UPDATE vips SET name = $1, title = $2, status = $3, updated_at = NOW()
WHERE id = $4 RETURNING *`,
[name, title, status, req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'VIP not found' });
}
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});
app.delete('/api/vips/:id', async (req, res) => {
if (!(req as any).user || (req as any).user.role !== 'administrator') {
return res.status(403).json({ error: 'Forbidden' });
}
try {
const result = await testPool.query('DELETE FROM vips WHERE id = $1 RETURNING id', [req.params.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'VIP not found' });
}
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});
// Setup test user and generate token
await insertTestUser(testPool, testUsers.admin);
const payload = createTestJwtPayload(testUsers.admin);
authToken = 'test-token';
(jwt.sign as jest.Mock).mockReturnValue(authToken);
(jwt.verify as jest.Mock).mockReturnValue(payload);
});
describe('GET /api/vips', () => {
it('should return all VIPs', async () => {
// Insert test VIPs
await insertTestVip(testPool, testVips.flightVip);
await insertTestVip(testPool, testVips.drivingVip);
const response = await request(app)
.get('/api/vips')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body[0].name).toBe(testVips.flightVip.name);
expect(response.body[1].name).toBe(testVips.drivingVip.name);
});
it('should return empty array when no VIPs exist', async () => {
const response = await request(app)
.get('/api/vips')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
});
describe('POST /api/vips', () => {
it('should create a new VIP when user is admin', async () => {
const newVip = {
name: 'New VIP',
title: 'CTO',
organization: 'Tech Corp',
arrival_datetime: '2025-01-20T15:00:00Z',
};
const response = await request(app)
.post('/api/vips')
.set('Authorization', `Bearer ${authToken}`)
.send(newVip);
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
name: newVip.name,
title: newVip.title,
organization: newVip.organization,
status: 'scheduled',
});
expect(response.body.id).toBeDefined();
});
it('should reject creation when user is not admin', async () => {
// Create coordinator user and token
await insertTestUser(testPool, testUsers.coordinator);
const coordPayload = createTestJwtPayload(testUsers.coordinator);
const coordToken = 'coord-token';
(jwt.verify as jest.Mock).mockReturnValueOnce(coordPayload);
const newVip = {
name: 'New VIP',
title: 'CTO',
organization: 'Tech Corp',
arrival_datetime: '2025-01-20T15:00:00Z',
};
const response = await request(app)
.post('/api/vips')
.set('Authorization', `Bearer ${coordToken}`)
.send(newVip);
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden');
});
});
describe('GET /api/vips/:id', () => {
it('should return a specific VIP', async () => {
await insertTestVip(testPool, testVips.flightVip);
const response = await request(app)
.get(`/api/vips/${testVips.flightVip.id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(testVips.flightVip.id);
expect(response.body.name).toBe(testVips.flightVip.name);
});
it('should return 404 for non-existent VIP', async () => {
const fakeId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const response = await request(app)
.get(`/api/vips/${fakeId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('VIP not found');
});
});
describe('PUT /api/vips/:id', () => {
it('should update a VIP', async () => {
await insertTestVip(testPool, testVips.flightVip);
const updates = {
name: 'Updated Name',
title: 'Updated Title',
status: 'arrived',
};
const response = await request(app)
.put(`/api/vips/${testVips.flightVip.id}`)
.set('Authorization', `Bearer ${authToken}`)
.send(updates);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updates.name);
expect(response.body.title).toBe(updates.title);
expect(response.body.status).toBe(updates.status);
});
it('should return 404 when updating non-existent VIP', async () => {
const fakeId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const response = await request(app)
.put(`/api/vips/${fakeId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('VIP not found');
});
it('should require authentication', async () => {
await insertTestVip(testPool, testVips.flightVip);
const response = await request(app)
.put(`/api/vips/${testVips.flightVip.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Unauthorized');
});
});
describe('DELETE /api/vips/:id', () => {
it('should delete a VIP when user is admin', async () => {
await insertTestVip(testPool, testVips.flightVip);
const response = await request(app)
.delete(`/api/vips/${testVips.flightVip.id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(204);
// Verify VIP was deleted
const checkResult = await testPool.query(
'SELECT * FROM vips WHERE id = $1',
[testVips.flightVip.id]
);
expect(checkResult.rows).toHaveLength(0);
});
it('should return 403 when non-admin tries to delete', async () => {
await insertTestVip(testPool, testVips.flightVip);
// Create coordinator user and token
await insertTestUser(testPool, testUsers.coordinator);
const coordPayload = createTestJwtPayload(testUsers.coordinator);
const coordToken = 'coord-token';
(jwt.verify as jest.Mock).mockReturnValueOnce(coordPayload);
const response = await request(app)
.delete(`/api/vips/${testVips.flightVip.id}`)
.set('Authorization', `Bearer ${coordToken}`);
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden');
});
});
});

View File

@@ -1,33 +1,116 @@
import express, { Request, Response, NextFunction } from 'express';
import {
generateToken,
verifyToken,
getGoogleAuthUrl,
exchangeCodeForTokens,
import {
generateToken,
verifyToken,
getGoogleAuthUrl,
exchangeCodeForTokens,
getGoogleUserInfo,
User
User
} from '../config/simpleAuth';
import databaseService from '../services/databaseService';
const router = express.Router();
// Enhanced logging for production debugging
function logAuthEvent(event: string, details: any = {}) {
const timestamp = new Date().toISOString();
console.log(`🔐 [AUTH ${timestamp}] ${event}:`, JSON.stringify(details, null, 2));
}
// Validate environment variables on startup
function validateAuthEnvironment() {
const required = ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REDIRECT_URI', 'FRONTEND_URL'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
logAuthEvent('ENVIRONMENT_ERROR', { missing_variables: missing });
return false;
}
// Validate URLs
const frontendUrl = process.env.FRONTEND_URL;
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
if (!frontendUrl?.startsWith('http')) {
logAuthEvent('ENVIRONMENT_ERROR', { error: 'FRONTEND_URL must start with http/https' });
return false;
}
if (!redirectUri?.startsWith('http')) {
logAuthEvent('ENVIRONMENT_ERROR', { error: 'GOOGLE_REDIRECT_URI must start with http/https' });
return false;
}
logAuthEvent('ENVIRONMENT_VALIDATED', {
frontend_url: frontendUrl,
redirect_uri: redirectUri,
client_id_configured: !!process.env.GOOGLE_CLIENT_ID,
client_secret_configured: !!process.env.GOOGLE_CLIENT_SECRET
});
return true;
}
// Validate environment on module load
const isEnvironmentValid = validateAuthEnvironment();
// Middleware to check authentication
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logAuthEvent('AUTH_FAILED', {
reason: 'no_token',
ip: req.ip,
path: req.path,
headers_present: !!req.headers.authorization
});
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
if (!token || token.length < 10) {
logAuthEvent('AUTH_FAILED', {
reason: 'invalid_token_format',
ip: req.ip,
path: req.path,
token_length: token?.length || 0
});
return res.status(401).json({ error: 'Invalid token format' });
}
const user = verifyToken(token);
if (!user) {
logAuthEvent('AUTH_FAILED', {
reason: 'token_verification_failed',
ip: req.ip,
path: req.path,
token_prefix: token.substring(0, 10) + '...'
});
return res.status(401).json({ error: 'Invalid or expired token' });
}
logAuthEvent('AUTH_SUCCESS', {
user_id: user.id,
user_email: user.email,
user_role: user.role,
ip: req.ip,
path: req.path
});
(req as any).user = user;
next();
} catch (error) {
logAuthEvent('AUTH_ERROR', {
error: error instanceof Error ? error.message : 'Unknown error',
ip: req.ip,
path: req.path
});
return res.status(500).json({ error: 'Authentication system error' });
}
const token = authHeader.substring(7);
const user = verifyToken(token);
if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}
(req as any).user = user;
next();
}
// Middleware to check role
@@ -50,19 +133,72 @@ router.get('/me', requireAuth, (req: Request, res: Response) => {
// Setup status endpoint (required by frontend)
router.get('/setup', async (req: Request, res: Response) => {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
try {
const userCount = await databaseService.getUserCount();
res.json({
setupCompleted: !!(clientId && clientSecret && clientId !== 'your-google-client-id-from-console'),
firstAdminCreated: userCount > 0,
oauthConfigured: !!(clientId && clientSecret)
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
const frontendUrl = process.env.FRONTEND_URL;
logAuthEvent('SETUP_CHECK', {
client_id_present: !!clientId,
client_secret_present: !!clientSecret,
redirect_uri_present: !!redirectUri,
frontend_url_present: !!frontendUrl,
environment_valid: isEnvironmentValid
});
// Check database connectivity
let userCount = 0;
let databaseConnected = false;
try {
userCount = await databaseService.getUserCount();
databaseConnected = true;
logAuthEvent('DATABASE_CHECK', { status: 'connected', user_count: userCount });
} catch (dbError) {
logAuthEvent('DATABASE_ERROR', {
error: dbError instanceof Error ? dbError.message : 'Unknown database error'
});
return res.status(500).json({
error: 'Database connection failed',
details: 'Cannot connect to PostgreSQL database'
});
}
const setupCompleted = !!(
clientId &&
clientSecret &&
redirectUri &&
frontendUrl &&
clientId !== 'your-google-client-id-from-console' &&
clientId !== 'your-google-client-id' &&
isEnvironmentValid
);
const response = {
setupCompleted,
firstAdminCreated: userCount > 0,
oauthConfigured: !!(clientId && clientSecret),
databaseConnected,
environmentValid: isEnvironmentValid,
configuration: {
google_oauth: !!(clientId && clientSecret),
redirect_uri_configured: !!redirectUri,
frontend_url_configured: !!frontendUrl,
production_ready: setupCompleted && databaseConnected
}
};
logAuthEvent('SETUP_STATUS', response);
res.json(response);
} catch (error) {
console.error('Error checking setup status:', error);
res.status(500).json({ error: 'Database connection error' });
logAuthEvent('SETUP_ERROR', {
error: error instanceof Error ? error.message : 'Unknown setup error'
});
res.status(500).json({
error: 'Setup check failed',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
@@ -80,25 +216,62 @@ router.get('/google', (req: Request, res: Response) => {
// Handle Google OAuth callback (this is where Google redirects back to)
router.get('/google/callback', async (req: Request, res: Response) => {
const { code, error } = req.query;
const { code, error, state } = req.query;
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
logAuthEvent('OAUTH_CALLBACK', {
has_code: !!code,
has_error: !!error,
error_type: error,
state,
frontend_url: frontendUrl,
ip: req.ip,
user_agent: req.get('User-Agent')
});
// Validate environment before proceeding
if (!isEnvironmentValid) {
logAuthEvent('OAUTH_CALLBACK_ERROR', { reason: 'invalid_environment' });
return res.redirect(`${frontendUrl}?error=configuration_error&message=OAuth not properly configured`);
}
if (error) {
console.error('OAuth error:', error);
return res.redirect(`${frontendUrl}?error=${error}`);
logAuthEvent('OAUTH_ERROR', { error, ip: req.ip });
return res.redirect(`${frontendUrl}?error=${error}&message=OAuth authorization failed`);
}
if (!code) {
return res.redirect(`${frontendUrl}?error=no_code`);
logAuthEvent('OAUTH_ERROR', { reason: 'no_authorization_code', ip: req.ip });
return res.redirect(`${frontendUrl}?error=no_code&message=No authorization code received`);
}
try {
logAuthEvent('OAUTH_TOKEN_EXCHANGE_START', { code_length: (code as string).length });
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code as string);
if (!tokens || !tokens.access_token) {
logAuthEvent('OAUTH_TOKEN_EXCHANGE_FAILED', { tokens_received: !!tokens });
return res.redirect(`${frontendUrl}?error=token_exchange_failed&message=Failed to exchange authorization code`);
}
logAuthEvent('OAUTH_TOKEN_EXCHANGE_SUCCESS', { has_access_token: !!tokens.access_token });
// Get user info
const googleUser = await getGoogleUserInfo(tokens.access_token);
if (!googleUser || !googleUser.email) {
logAuthEvent('OAUTH_USER_INFO_FAILED', { user_data: !!googleUser });
return res.redirect(`${frontendUrl}?error=user_info_failed&message=Failed to get user information from Google`);
}
logAuthEvent('OAUTH_USER_INFO_SUCCESS', {
email: googleUser.email,
name: googleUser.name,
verified_email: googleUser.verified_email
});
// Check if user exists or create new user
let user = await databaseService.getUserByEmail(googleUser.email);
@@ -107,6 +280,12 @@ router.get('/google/callback', async (req: Request, res: Response) => {
const approvedUserCount = await databaseService.getApprovedUserCount();
const role = approvedUserCount === 0 ? 'administrator' : 'coordinator';
logAuthEvent('USER_CREATION', {
email: googleUser.email,
role,
is_first_user: approvedUserCount === 0
});
user = await databaseService.createUser({
id: googleUser.id,
google_id: googleUser.id,
@@ -120,28 +299,49 @@ router.get('/google/callback', async (req: Request, res: Response) => {
if (approvedUserCount === 0) {
await databaseService.updateUserApprovalStatus(googleUser.email, 'approved');
user.approval_status = 'approved';
logAuthEvent('FIRST_ADMIN_CREATED', { email: googleUser.email });
} else {
logAuthEvent('USER_PENDING_APPROVAL', { email: googleUser.email });
}
} else {
// Update last sign in
await databaseService.updateUserLastSignIn(googleUser.email);
console.log(`✅ User logged in: ${user.name} (${user.email})`);
logAuthEvent('USER_LOGIN', {
email: user.email,
name: user.name,
role: user.role,
approval_status: user.approval_status
});
}
// Check if user is approved
if (user.approval_status !== 'approved') {
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
logAuthEvent('USER_NOT_APPROVED', { email: user.email, status: user.approval_status });
return res.redirect(`${frontendUrl}?error=pending_approval&message=Your account is pending administrator approval`);
}
// Generate JWT token
const token = generateToken(user);
logAuthEvent('JWT_TOKEN_GENERATED', {
user_id: user.id,
email: user.email,
role: user.role,
token_length: token.length
});
// Redirect to frontend with token
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
const callbackUrl = `${frontendUrl}/auth/callback?token=${token}`;
logAuthEvent('OAUTH_SUCCESS_REDIRECT', { callback_url: callbackUrl });
res.redirect(callbackUrl);
} catch (error) {
console.error('Error in OAuth callback:', error);
res.redirect(`${frontendUrl}?error=oauth_failed`);
logAuthEvent('OAUTH_CALLBACK_ERROR', {
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
ip: req.ip
});
res.redirect(`${frontendUrl}?error=oauth_failed&message=Authentication failed due to server error`);
}
});

View File

@@ -0,0 +1,55 @@
-- Script to check current users and fix the first user to be admin
-- 1. Show all users in the system
SELECT
email,
name,
role,
approval_status,
status,
created_at,
last_login,
is_active
FROM users
ORDER BY created_at ASC;
-- 2. Show the first user (by creation date)
SELECT
'=== FIRST USER ===' as info,
email,
name,
role,
approval_status,
created_at
FROM users
WHERE created_at = (SELECT MIN(created_at) FROM users);
-- 3. Fix the first user to be administrator
UPDATE users
SET
role = 'administrator',
approval_status = 'approved',
status = COALESCE(status, 'active'),
updated_at = CURRENT_TIMESTAMP
WHERE created_at = (SELECT MIN(created_at) FROM users)
RETURNING
'=== UPDATED USER ===' as info,
email,
name,
role,
approval_status,
status;
-- 4. Show all users again to confirm the change
SELECT
'=== ALL USERS AFTER UPDATE ===' as info;
SELECT
email,
name,
role,
approval_status,
status,
created_at
FROM users
ORDER BY created_at ASC;

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env node
import { Pool } from 'pg';
import { getMigrationService, MigrationService } from '../services/migrationService';
import { createSeedService } from '../services/seedService';
import { env } from '../config/env';
// Command line arguments
const command = process.argv[2];
const args = process.argv.slice(3);
// Create database pool
const pool = new Pool({
connectionString: env.DATABASE_URL,
});
async function main() {
try {
switch (command) {
case 'migrate':
await runMigrations();
break;
case 'migrate:create':
await createMigration(args[0]);
break;
case 'seed':
await seedDatabase();
break;
case 'seed:reset':
await resetAndSeed();
break;
case 'setup':
await setupDatabase();
break;
default:
showHelp();
process.exit(1);
}
} catch (error) {
console.error('❌ Error:', error);
process.exit(1);
} finally {
await pool.end();
}
}
async function runMigrations() {
console.log('🔄 Running migrations...');
const migrationService = getMigrationService(pool);
await migrationService.runMigrations();
}
async function createMigration(name?: string) {
if (!name) {
console.error('❌ Please provide a migration name');
console.log('Usage: npm run db:migrate:create <migration-name>');
process.exit(1);
}
await MigrationService.createMigration(name);
}
async function seedDatabase() {
console.log('🌱 Seeding database...');
const seedService = createSeedService(pool);
await seedService.seedAll();
}
async function resetAndSeed() {
console.log('🔄 Resetting and seeding database...');
const seedService = createSeedService(pool);
await seedService.resetAndSeed();
}
async function setupDatabase() {
console.log('🚀 Setting up database...');
// Run initial schema
const fs = await import('fs/promises');
const path = await import('path');
const schemaPath = path.join(__dirname, '..', 'config', 'schema.sql');
const schema = await fs.readFile(schemaPath, 'utf8');
await pool.query(schema);
console.log('✅ Created database schema');
// Run migrations
const migrationService = getMigrationService(pool);
await migrationService.runMigrations();
// Seed initial data
const seedService = createSeedService(pool);
await seedService.seedAll();
console.log('✅ Database setup complete!');
}
function showHelp() {
console.log(`
VIP Coordinator Database CLI
Usage: npm run db:<command>
Commands:
migrate Run pending migrations
migrate:create Create a new migration file
seed Seed the database with test data
seed:reset Clear all data and re-seed
setup Run schema, migrations, and seed data
Examples:
npm run db:migrate
npm run db:migrate:create add_new_column
npm run db:seed
npm run db:setup
`);
}
// Run the CLI
main().catch(console.error);

View File

@@ -0,0 +1,85 @@
// Script to fix the existing Google-authenticated user to be admin
// This will update the first user (by creation date) to have administrator role
const { Pool } = require('pg');
// Using the postgres user since we know that password
const DATABASE_URL = process.env.DATABASE_URL ||
'postgresql://postgres:changeme@localhost:5432/vip_coordinator';
console.log('Connecting to database...');
const pool = new Pool({
connectionString: DATABASE_URL,
ssl: false
});
async function fixExistingUserToAdmin() {
try {
// 1. Show current users
console.log('\n📋 Current Google-authenticated users:');
console.log('=====================================');
const allUsers = await pool.query(`
SELECT email, name, role, created_at, is_active
FROM users
ORDER BY created_at ASC
`);
if (allUsers.rows.length === 0) {
console.log('❌ No users found in database!');
console.log('\nThe first user needs to log in with Google first.');
return;
}
console.log(`Found ${allUsers.rows.length} user(s):\n`);
allUsers.rows.forEach((user, index) => {
console.log(`User #${index + 1}:`);
console.log(` Email: ${user.email}`);
console.log(` Name: ${user.name}`);
console.log(` Current Role: ${user.role} ${user.role !== 'administrator' ? '❌' : '✅'}`);
console.log(` Is Active: ${user.is_active ? 'Yes' : 'No'}`);
console.log(` Created: ${user.created_at}`);
console.log('');
});
// 2. Update the first user to administrator
const firstUser = allUsers.rows[0];
if (firstUser.role === 'administrator') {
console.log('✅ First user is already an administrator!');
return;
}
console.log(`🔧 Updating ${firstUser.name} (${firstUser.email}) to administrator...`);
const updateResult = await pool.query(`
UPDATE users
SET
role = 'administrator',
is_active = true,
updated_at = CURRENT_TIMESTAMP
WHERE email = $1
RETURNING email, name, role, is_active
`, [firstUser.email]);
if (updateResult.rows.length > 0) {
const updated = updateResult.rows[0];
console.log('\n✅ Successfully updated user!');
console.log(` Email: ${updated.email}`);
console.log(` Name: ${updated.name}`);
console.log(` New Role: ${updated.role}`);
console.log(` Is Active: ${updated.is_active ? 'Yes' : 'No'}`);
console.log('\n🎉 This user can now log in and access the Admin dashboard!');
}
} catch (error) {
console.error('\n❌ Error:', error.message);
if (error.code === '28P01') {
console.error('\nPassword authentication failed. Make sure Docker containers are running.');
}
} finally {
await pool.end();
}
}
// Run the fix
fixExistingUserToAdmin();

View File

@@ -0,0 +1,77 @@
// Script to check users and fix the first user to be admin
// Run with: node backend/src/scripts/fix-first-admin-docker.js
const { Pool } = require('pg');
// Construct DATABASE_URL from docker-compose defaults
const DATABASE_URL = process.env.DATABASE_URL ||
`postgresql://vip_user:${process.env.DB_PASSWORD || 'VipCoord2025SecureDB'}@localhost:5432/vip_coordinator`;
console.log('Connecting to database...');
const pool = new Pool({
connectionString: DATABASE_URL,
ssl: false // Local docker doesn't use SSL
});
async function fixFirstAdmin() {
try {
// 1. Show all current users
console.log('\n📋 Current users in database:');
console.log('================================');
const allUsers = await pool.query(`
SELECT email, name, role, approval_status, status, created_at
FROM users
ORDER BY created_at ASC
`);
if (allUsers.rows.length === 0) {
console.log('No users found in database!');
return;
}
allUsers.rows.forEach(user => {
console.log(`
Email: ${user.email}
Name: ${user.name}
Role: ${user.role}
Approval: ${user.approval_status || 'N/A'}
Status: ${user.status || 'N/A'}
Created: ${user.created_at}
------`);
});
// 2. Fix the first user to be admin
console.log('\n🔧 Updating first user to administrator...');
const updateResult = await pool.query(`
UPDATE users
SET
role = 'administrator',
approval_status = 'approved',
status = COALESCE(status, 'active'),
updated_at = CURRENT_TIMESTAMP
WHERE created_at = (SELECT MIN(created_at) FROM users)
RETURNING email, name, role, approval_status, status
`);
if (updateResult.rows.length > 0) {
const updated = updateResult.rows[0];
console.log('\n✅ Successfully updated user:');
console.log(`Email: ${updated.email}`);
console.log(`Name: ${updated.name}`);
console.log(`New Role: ${updated.role}`);
console.log(`Status: ${updated.status}`);
} else {
console.log('\n❌ No users found to update!');
}
} catch (error) {
console.error('\n❌ Error:', error.message);
console.error('Full error:', error);
} finally {
await pool.end();
}
}
// Run the fix
fixFirstAdmin();

View File

@@ -0,0 +1,66 @@
// Script to check users and fix the first user to be admin
// Run with: node backend/src/scripts/fix-first-admin.js
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
async function fixFirstAdmin() {
try {
// 1. Show all current users
console.log('\n📋 Current users in database:');
console.log('================================');
const allUsers = await pool.query(`
SELECT email, name, role, approval_status, status, created_at
FROM users
ORDER BY created_at ASC
`);
allUsers.rows.forEach(user => {
console.log(`
Email: ${user.email}
Name: ${user.name}
Role: ${user.role}
Approval: ${user.approval_status || 'N/A'}
Status: ${user.status || 'N/A'}
Created: ${user.created_at}
------`);
});
// 2. Fix the first user to be admin
console.log('\n🔧 Updating first user to administrator...');
const updateResult = await pool.query(`
UPDATE users
SET
role = 'administrator',
approval_status = 'approved',
status = COALESCE(status, 'active'),
updated_at = CURRENT_TIMESTAMP
WHERE created_at = (SELECT MIN(created_at) FROM users)
RETURNING email, name, role, approval_status, status
`);
if (updateResult.rows.length > 0) {
const updated = updateResult.rows[0];
console.log('\n✅ Successfully updated user:');
console.log(`Email: ${updated.email}`);
console.log(`Name: ${updated.name}`);
console.log(`New Role: ${updated.role}`);
console.log(`Status: ${updated.status}`);
} else {
console.log('\n❌ No users found to update!');
}
} catch (error) {
console.error('\n❌ Error:', error.message);
} finally {
await pool.end();
}
}
// Run the fix
fixFirstAdmin();

View File

@@ -0,0 +1,102 @@
// Script to fix cbtah56@gmail.com to be admin
const { Pool } = require('pg');
const DATABASE_URL = 'postgresql://postgres:changeme@localhost:5432/vip_coordinator';
console.log('Connecting to database...');
const pool = new Pool({
connectionString: DATABASE_URL,
ssl: false
});
async function fixSpecificUser() {
try {
// 1. Show ALL users
console.log('\n📋 ALL users in database:');
console.log('========================');
const allUsers = await pool.query(`
SELECT email, name, role, created_at, is_active
FROM users
ORDER BY created_at ASC
`);
console.log(`Total users found: ${allUsers.rows.length}\n`);
allUsers.rows.forEach((user, index) => {
console.log(`User #${index + 1}:`);
console.log(` Email: ${user.email}`);
console.log(` Name: ${user.name}`);
console.log(` Role: ${user.role}`);
console.log(` Is Active: ${user.is_active}`);
console.log(` Created: ${user.created_at}`);
console.log('---');
});
// 2. Look specifically for cbtah56@gmail.com
console.log('\n🔍 Looking for cbtah56@gmail.com...');
const targetUser = await pool.query(`
SELECT email, name, role, created_at, is_active
FROM users
WHERE email = 'cbtah56@gmail.com'
`);
if (targetUser.rows.length === 0) {
console.log('❌ User cbtah56@gmail.com not found in database!');
// Try case-insensitive search
console.log('\n🔍 Trying case-insensitive search...');
const caseInsensitive = await pool.query(`
SELECT email, name, role, created_at, is_active
FROM users
WHERE LOWER(email) = LOWER('cbtah56@gmail.com')
`);
if (caseInsensitive.rows.length > 0) {
console.log('Found with different case:', caseInsensitive.rows[0].email);
}
return;
}
const user = targetUser.rows[0];
console.log('\n✅ Found user:');
console.log(` Email: ${user.email}`);
console.log(` Name: ${user.name}`);
console.log(` Current Role: ${user.role}`);
if (user.role === 'administrator') {
console.log('\n✅ User is already an administrator!');
return;
}
// 3. Update to administrator
console.log('\n🔧 Updating cbtah56@gmail.com to administrator...');
const updateResult = await pool.query(`
UPDATE users
SET
role = 'administrator',
is_active = true,
updated_at = CURRENT_TIMESTAMP
WHERE email = 'cbtah56@gmail.com'
RETURNING email, name, role, is_active
`);
if (updateResult.rows.length > 0) {
const updated = updateResult.rows[0];
console.log('\n✅ Successfully updated user!');
console.log(` Email: ${updated.email}`);
console.log(` Name: ${updated.name}`);
console.log(` New Role: ${updated.role}`);
console.log('\n🎉 cbtah56@gmail.com can now log in and access the Admin dashboard!');
}
} catch (error) {
console.error('\n❌ Error:', error.message);
} finally {
await pool.end();
}
}
// Run the fix
fixSpecificUser();

View File

@@ -0,0 +1,249 @@
import { testPool } from '../../tests/setup';
import {
testUsers,
insertTestUser,
createTestJwtPayload
} from '../../tests/fixtures';
import { OAuth2Client } from 'google-auth-library';
// Mock dependencies
jest.mock('google-auth-library');
jest.mock('../jwtKeyManager');
describe('AuthService', () => {
let mockOAuth2Client: jest.Mocked<OAuth2Client>;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Setup OAuth2Client mock
mockOAuth2Client = new OAuth2Client() as jest.Mocked<OAuth2Client>;
(OAuth2Client as jest.Mock).mockImplementation(() => mockOAuth2Client);
});
describe('Google OAuth Verification', () => {
it('should create a new user on first sign-in with admin role', async () => {
// Mock Google token verification
mockOAuth2Client.verifyIdToken = jest.fn().mockResolvedValue({
getPayload: () => ({
sub: 'google_new_user_123',
email: 'newuser@test.com',
name: 'New User',
picture: 'https://example.com/picture.jpg',
}),
});
// Check no users exist
const userCount = await testPool.query('SELECT COUNT(*) FROM users');
expect(userCount.rows[0].count).toBe('0');
// TODO: Call auth service to verify token and create user
// This would normally call your authService.verifyGoogleToken() method
// Verify user was created with admin role
const newUser = await testPool.query(
'SELECT * FROM users WHERE email = $1',
['newuser@test.com']
);
// Simulate what the service should do
await testPool.query(`
INSERT INTO users (
id, google_id, email, name, role, status, approval_status,
profile_picture_url, created_at, is_active
) VALUES (
gen_random_uuid(), $1, $2, $3, 'administrator', 'active', 'approved',
$4, NOW(), true
)
`, ['google_new_user_123', 'newuser@test.com', 'New User', 'https://example.com/picture.jpg']);
const createdUser = await testPool.query(
'SELECT * FROM users WHERE email = $1',
['newuser@test.com']
);
expect(createdUser.rows).toHaveLength(1);
expect(createdUser.rows[0].role).toBe('administrator');
expect(createdUser.rows[0].status).toBe('active');
});
it('should create subsequent users with coordinator role and pending status', async () => {
// Insert first user (admin)
await insertTestUser(testPool, testUsers.admin);
// Mock Google token verification for second user
mockOAuth2Client.verifyIdToken = jest.fn().mockResolvedValue({
getPayload: () => ({
sub: 'google_second_user_456',
email: 'seconduser@test.com',
name: 'Second User',
picture: 'https://example.com/picture2.jpg',
}),
});
// TODO: Call auth service to verify token and create user
// Simulate what the service should do
await testPool.query(`
INSERT INTO users (
id, google_id, email, name, role, status, approval_status,
profile_picture_url, created_at, is_active
) VALUES (
gen_random_uuid(), $1, $2, $3, 'coordinator', 'pending', 'pending',
$4, NOW(), true
)
`, ['google_second_user_456', 'seconduser@test.com', 'Second User', 'https://example.com/picture2.jpg']);
const secondUser = await testPool.query(
'SELECT * FROM users WHERE email = $1',
['seconduser@test.com']
);
expect(secondUser.rows).toHaveLength(1);
expect(secondUser.rows[0].role).toBe('coordinator');
expect(secondUser.rows[0].status).toBe('pending');
expect(secondUser.rows[0].approval_status).toBe('pending');
});
it('should handle existing user login', async () => {
// Insert existing user
await insertTestUser(testPool, testUsers.coordinator);
// Mock Google token verification
mockOAuth2Client.verifyIdToken = jest.fn().mockResolvedValue({
getPayload: () => ({
sub: testUsers.coordinator.google_id,
email: testUsers.coordinator.email,
name: testUsers.coordinator.name,
picture: testUsers.coordinator.profile_picture_url,
}),
});
// TODO: Call auth service to verify token
// Update last login time (what the service should do)
await testPool.query(
'UPDATE users SET last_login = NOW() WHERE email = $1',
[testUsers.coordinator.email]
);
const updatedUser = await testPool.query(
'SELECT * FROM users WHERE email = $1',
[testUsers.coordinator.email]
);
expect(updatedUser.rows[0].last_login).not.toBeNull();
});
it('should reject invalid Google tokens', async () => {
// Mock Google token verification to throw error
mockOAuth2Client.verifyIdToken = jest.fn().mockRejectedValue(
new Error('Invalid token')
);
// TODO: Call auth service and expect it to throw/reject
await expect(
mockOAuth2Client.verifyIdToken({ idToken: 'invalid', audience: 'test' })
).rejects.toThrow('Invalid token');
});
});
describe('User Management', () => {
it('should approve a pending user', async () => {
// Insert admin and pending user
await insertTestUser(testPool, testUsers.admin);
await insertTestUser(testPool, testUsers.pendingUser);
// TODO: Call auth service to approve user
// Simulate approval
await testPool.query(`
UPDATE users
SET status = 'active',
approval_status = 'approved',
approved_by = $1,
approved_at = NOW()
WHERE id = $2
`, [testUsers.admin.id, testUsers.pendingUser.id]);
const approvedUser = await testPool.query(
'SELECT * FROM users WHERE id = $1',
[testUsers.pendingUser.id]
);
expect(approvedUser.rows[0].status).toBe('active');
expect(approvedUser.rows[0].approval_status).toBe('approved');
expect(approvedUser.rows[0].approved_by).toBe(testUsers.admin.id);
expect(approvedUser.rows[0].approved_at).not.toBeNull();
});
it('should deny a pending user', async () => {
// Insert admin and pending user
await insertTestUser(testPool, testUsers.admin);
await insertTestUser(testPool, testUsers.pendingUser);
// TODO: Call auth service to deny user
// Simulate denial
await testPool.query(`
UPDATE users
SET approval_status = 'denied',
approved_by = $1,
approved_at = NOW()
WHERE id = $2
`, [testUsers.admin.id, testUsers.pendingUser.id]);
const deniedUser = await testPool.query(
'SELECT * FROM users WHERE id = $1',
[testUsers.pendingUser.id]
);
expect(deniedUser.rows[0].status).toBe('pending');
expect(deniedUser.rows[0].approval_status).toBe('denied');
});
it('should deactivate an active user', async () => {
// Insert admin and active user
await insertTestUser(testPool, testUsers.admin);
await insertTestUser(testPool, testUsers.coordinator);
// TODO: Call auth service to deactivate user
// Simulate deactivation
await testPool.query(`
UPDATE users
SET status = 'deactivated',
is_active = false
WHERE id = $1
`, [testUsers.coordinator.id]);
const deactivatedUser = await testPool.query(
'SELECT * FROM users WHERE id = $1',
[testUsers.coordinator.id]
);
expect(deactivatedUser.rows[0].status).toBe('deactivated');
expect(deactivatedUser.rows[0].is_active).toBe(false);
});
});
describe('JWT Token Generation', () => {
it('should generate JWT with all required fields', () => {
const payload = createTestJwtPayload(testUsers.admin);
expect(payload).toHaveProperty('id');
expect(payload).toHaveProperty('email');
expect(payload).toHaveProperty('name');
expect(payload).toHaveProperty('role');
expect(payload).toHaveProperty('status');
expect(payload).toHaveProperty('approval_status');
expect(payload).toHaveProperty('iat');
expect(payload).toHaveProperty('exp');
// Verify expiration is in the future
expect(payload.exp).toBeGreaterThan(payload.iat);
});
});
});

View File

@@ -0,0 +1,197 @@
const jwt = require('jsonwebtoken');
import { Request, Response, NextFunction } from 'express';
import { OAuth2Client } from 'google-auth-library';
import dataService from './unifiedDataService';
// Simplified authentication service - removes excessive logging and complexity
class AuthService {
private jwtSecret: string;
private jwtExpiry: string = '24h';
private googleClient: OAuth2Client;
constructor() {
// Auto-generate a secure JWT secret if not provided
if (process.env.JWT_SECRET) {
this.jwtSecret = process.env.JWT_SECRET;
console.log('Using JWT_SECRET from environment');
} else {
// Generate a cryptographically secure random secret
const crypto = require('crypto');
this.jwtSecret = crypto.randomBytes(64).toString('hex');
console.log('Generated new JWT_SECRET (this will change on restart)');
console.log('To persist sessions across restarts, set JWT_SECRET in .env');
}
// Initialize Google OAuth client
this.googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
}
// Generate JWT token
generateToken(user: any): string {
const payload = { id: user.id, email: user.email, role: user.role };
return jwt.sign(payload, this.jwtSecret, { expiresIn: this.jwtExpiry }) as string;
}
// Verify Google ID token from frontend
async verifyGoogleToken(credential: string): Promise<{ user: any; token: string }> {
try {
// Verify the token with Google
const ticket = await this.googleClient.verifyIdToken({
idToken: credential,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload || !payload.email) {
throw new Error('Invalid token payload');
}
// Find or create user
let user = await dataService.getUserByEmail(payload.email);
if (!user) {
// Auto-create user with coordinator role
user = await dataService.createUser({
email: payload.email,
name: payload.name || payload.email,
role: 'coordinator',
googleId: payload.sub
});
}
// Generate our JWT
const token = this.generateToken(user);
return { user, token };
} catch (error) {
console.error('Token verification error:', error);
throw new Error('Failed to verify Google token');
}
}
// Verify JWT token
verifyToken(token: string): any {
try {
return jwt.verify(token, this.jwtSecret);
} catch (error) {
return null;
}
}
// Middleware to check authentication
requireAuth = async (req: Request & { user?: any }, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
const decoded = this.verifyToken(token);
if (!decoded) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
// Get fresh user data
const user = await dataService.getUserById(decoded.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
};
// Middleware to check role
requireRole = (roles: string[]) => {
return (req: Request & { user?: any }, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};
// Google OAuth helpers
getGoogleAuthUrl(): string {
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_REDIRECT_URI) {
throw new Error('Google OAuth not configured. Please set GOOGLE_CLIENT_ID and GOOGLE_REDIRECT_URI in .env file');
}
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: process.env.GOOGLE_REDIRECT_URI,
response_type: 'code',
scope: 'email profile',
access_type: 'offline',
prompt: 'consent'
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}
async exchangeGoogleCode(code: string): Promise<any> {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: process.env.GOOGLE_REDIRECT_URI,
grant_type: 'authorization_code'
})
});
if (!response.ok) {
throw new Error('Failed to exchange authorization code');
}
return response.json();
}
async getGoogleUserInfo(accessToken: string): Promise<any> {
const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!response.ok) {
throw new Error('Failed to get user info');
}
return response.json();
}
// Simplified login/signup
async handleGoogleAuth(code: string): Promise<{ user: any; token: string }> {
// Exchange code for tokens
const tokens = await this.exchangeGoogleCode(code);
// Get user info
const googleUser = await this.getGoogleUserInfo(tokens.access_token);
// Find or create user
let user = await dataService.getUserByEmail(googleUser.email);
if (!user) {
// Auto-create user with coordinator role
user = await dataService.createUser({
email: googleUser.email,
name: googleUser.name,
role: 'coordinator',
googleId: googleUser.id
});
}
// Generate JWT
const token = this.generateToken(user);
return { user, token };
}
}
export default new AuthService();

View File

@@ -128,21 +128,21 @@ class FlightService {
}
// Check for API errors in response
if (data.error) {
console.error('AviationStack API error:', data.error);
if ((data as any).error) {
console.error('AviationStack API error:', (data as any).error);
return null;
}
if (data.data && data.data.length > 0) {
if ((data as any).data && (data as any).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);
let flight = (data as any).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];
flight = (data as any).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'}`);

View File

@@ -0,0 +1,180 @@
import { Pool } from 'pg';
import * as fs from 'fs/promises';
import * as path from 'path';
import { env } from '../config/env';
export class MigrationService {
private pool: Pool;
private migrationsPath: string;
constructor(pool: Pool) {
this.pool = pool;
this.migrationsPath = path.join(__dirname, '..', 'migrations');
}
/**
* Initialize migrations table
*/
async initializeMigrationsTable(): Promise<void> {
const query = `
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
checksum VARCHAR(64) NOT NULL
);
`;
await this.pool.query(query);
}
/**
* Get list of applied migrations
*/
async getAppliedMigrations(): Promise<Set<string>> {
const result = await this.pool.query(
'SELECT filename FROM migrations ORDER BY applied_at'
);
return new Set(result.rows.map(row => row.filename));
}
/**
* Calculate checksum for migration file
*/
private async calculateChecksum(content: string): Promise<string> {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Get all migration files sorted by name
*/
async getMigrationFiles(): Promise<string[]> {
try {
const files = await fs.readdir(this.migrationsPath);
return files
.filter(file => file.endsWith('.sql'))
.sort(); // Ensures migrations run in order
} catch (error) {
// If migrations directory doesn't exist, return empty array
return [];
}
}
/**
* Apply a single migration
*/
async applyMigration(filename: string): Promise<void> {
const filepath = path.join(this.migrationsPath, filename);
const content = await fs.readFile(filepath, 'utf8');
const checksum = await this.calculateChecksum(content);
// Check if migration was already applied
const existing = await this.pool.query(
'SELECT checksum FROM migrations WHERE filename = $1',
[filename]
);
if (existing.rows.length > 0) {
if (existing.rows[0].checksum !== checksum) {
throw new Error(
`Migration ${filename} has been modified after being applied!`
);
}
return; // Migration already applied
}
// Start transaction
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Execute migration
await client.query(content);
// Record migration
await client.query(
'INSERT INTO migrations (filename, checksum) VALUES ($1, $2)',
[filename, checksum]
);
await client.query('COMMIT');
console.log(`✅ Applied migration: ${filename}`);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Run all pending migrations
*/
async runMigrations(): Promise<void> {
console.log('🔄 Checking for pending migrations...');
// Initialize migrations table
await this.initializeMigrationsTable();
// Get applied migrations
const appliedMigrations = await this.getAppliedMigrations();
// Get all migration files
const migrationFiles = await this.getMigrationFiles();
// Filter pending migrations
const pendingMigrations = migrationFiles.filter(
file => !appliedMigrations.has(file)
);
if (pendingMigrations.length === 0) {
console.log('✨ No pending migrations');
return;
}
console.log(`📦 Found ${pendingMigrations.length} pending migrations`);
// Apply each migration
for (const migration of pendingMigrations) {
await this.applyMigration(migration);
}
console.log('✅ All migrations completed successfully');
}
/**
* Create a new migration file
*/
static async createMigration(name: string): Promise<string> {
const timestamp = new Date().toISOString()
.replace(/[-:]/g, '')
.replace('T', '_')
.split('.')[0];
const filename = `${timestamp}_${name.toLowerCase().replace(/\s+/g, '_')}.sql`;
const filepath = path.join(__dirname, '..', 'migrations', filename);
const template = `-- Migration: ${name}
-- Created: ${new Date().toISOString()}
-- Add your migration SQL here
`;
await fs.writeFile(filepath, template);
console.log(`Created migration: ${filename}`);
return filename;
}
}
// Export a singleton instance
let migrationService: MigrationService | null = null;
export function getMigrationService(pool: Pool): MigrationService {
if (!migrationService) {
migrationService = new MigrationService(pool);
}
return migrationService;
}

View File

@@ -0,0 +1,285 @@
import { Pool } from 'pg';
import { v4 as uuidv4 } from 'uuid';
export class SeedService {
private pool: Pool;
constructor(pool: Pool) {
this.pool = pool;
}
/**
* Clear all data from tables (for testing)
*/
async clearAllData(): Promise<void> {
const tables = [
'schedule_events',
'flights',
'drivers',
'vips',
'admin_settings',
'users',
'system_setup'
];
for (const table of tables) {
await this.pool.query(`TRUNCATE TABLE ${table} CASCADE`);
}
console.log('🗑️ Cleared all data');
}
/**
* Seed test users
*/
async seedUsers(): Promise<void> {
const users = [
{
id: uuidv4(),
google_id: 'google_admin_' + Date.now(),
email: 'admin@example.com',
name: 'Admin User',
role: 'administrator',
status: 'active',
approval_status: 'approved',
profile_picture_url: 'https://via.placeholder.com/150',
organization: 'VIP Transportation Inc',
phone: '+1 555-0100',
},
{
id: uuidv4(),
google_id: 'google_coord_' + Date.now(),
email: 'coordinator@example.com',
name: 'Coordinator User',
role: 'coordinator',
status: 'active',
approval_status: 'approved',
profile_picture_url: 'https://via.placeholder.com/150',
organization: 'VIP Transportation Inc',
phone: '+1 555-0101',
},
{
id: uuidv4(),
google_id: 'google_driver_' + Date.now(),
email: 'driver@example.com',
name: 'Driver User',
role: 'driver',
status: 'active',
approval_status: 'approved',
profile_picture_url: 'https://via.placeholder.com/150',
organization: 'VIP Transportation Inc',
phone: '+1 555-0102',
},
];
for (const user of users) {
await this.pool.query(
`INSERT INTO users (
id, google_id, email, name, role, status, approval_status,
profile_picture_url, organization, phone, created_at, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), true)
ON CONFLICT (email) DO NOTHING`,
[
user.id,
user.google_id,
user.email,
user.name,
user.role,
user.status,
user.approval_status,
user.profile_picture_url,
user.organization,
user.phone,
]
);
}
console.log('👤 Seeded users');
}
/**
* Seed test drivers
*/
async seedDrivers(): Promise<void> {
const drivers = [
{
id: uuidv4(),
name: 'John Smith',
phone: '+1 555-1001',
email: 'john.smith@drivers.com',
license_number: 'DL123456',
vehicle_info: '2023 Mercedes S-Class - Black',
availability_status: 'available',
current_location: 'Downtown Station',
notes: 'Experienced with VIP transport, speaks English and Spanish',
},
{
id: uuidv4(),
name: 'Sarah Johnson',
phone: '+1 555-1002',
email: 'sarah.johnson@drivers.com',
license_number: 'DL789012',
vehicle_info: '2023 BMW 7 Series - Silver',
availability_status: 'available',
current_location: 'Airport Terminal 1',
notes: 'Airport specialist, knows all terminals',
},
{
id: uuidv4(),
name: 'Michael Chen',
phone: '+1 555-1003',
email: 'michael.chen@drivers.com',
license_number: 'DL345678',
vehicle_info: '2023 Tesla Model S - White',
availability_status: 'busy',
current_location: 'En route to LAX',
notes: 'Tech-savvy, preferred for tech executives',
},
];
for (const driver of drivers) {
await this.pool.query(
`INSERT INTO drivers (
id, name, phone, email, license_number, vehicle_info,
availability_status, current_location, notes, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
ON CONFLICT (email) DO NOTHING`,
[
driver.id,
driver.name,
driver.phone,
driver.email,
driver.license_number,
driver.vehicle_info,
driver.availability_status,
driver.current_location,
driver.notes,
]
);
}
console.log('🚗 Seeded drivers');
}
/**
* Seed test VIPs
*/
async seedVips(): Promise<void> {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dayAfter = new Date();
dayAfter.setDate(dayAfter.getDate() + 2);
const vips = [
{
id: uuidv4(),
name: 'Robert Johnson',
title: 'CEO',
organization: 'Tech Innovations Corp',
contact_info: '+1 555-2001',
arrival_datetime: tomorrow.toISOString(),
departure_datetime: dayAfter.toISOString(),
airport: 'LAX',
flight_number: 'AA1234',
hotel: 'Beverly Hills Hotel',
room_number: '501',
status: 'scheduled',
transportation_mode: 'flight',
notes: 'Requires luxury vehicle, allergic to pets',
},
{
id: uuidv4(),
name: 'Emily Davis',
title: 'VP of Sales',
organization: 'Global Marketing Inc',
contact_info: '+1 555-2002',
arrival_datetime: tomorrow.toISOString(),
departure_datetime: dayAfter.toISOString(),
hotel: 'Four Seasons',
room_number: '1201',
status: 'scheduled',
transportation_mode: 'self_driving',
notes: 'Arriving by personal vehicle, needs parking arrangements',
},
{
id: uuidv4(),
name: 'David Wilson',
title: 'Director of Operations',
organization: 'Finance Solutions Ltd',
contact_info: '+1 555-2003',
arrival_datetime: new Date().toISOString(),
departure_datetime: tomorrow.toISOString(),
airport: 'LAX',
flight_number: 'UA5678',
hotel: 'Ritz Carlton',
room_number: '802',
status: 'arrived',
transportation_mode: 'flight',
notes: 'Currently at hotel, needs pickup for meetings tomorrow',
},
];
for (const vip of vips) {
await this.pool.query(
`INSERT INTO vips (
id, name, title, organization, contact_info, arrival_datetime,
departure_datetime, airport, flight_number, hotel, room_number,
status, transportation_mode, notes, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW())
ON CONFLICT (id) DO NOTHING`,
[
vip.id,
vip.name,
vip.title,
vip.organization,
vip.contact_info,
vip.arrival_datetime,
vip.departure_datetime,
vip.airport || null,
vip.flight_number || null,
vip.hotel,
vip.room_number,
vip.status,
vip.transportation_mode,
vip.notes,
]
);
}
console.log('⭐ Seeded VIPs');
}
/**
* Seed all test data
*/
async seedAll(): Promise<void> {
console.log('🌱 Starting database seeding...');
try {
await this.seedUsers();
await this.seedDrivers();
await this.seedVips();
console.log('✅ Database seeding completed successfully');
} catch (error) {
console.error('❌ Error seeding database:', error);
throw error;
}
}
/**
* Reset and seed (for development)
*/
async resetAndSeed(): Promise<void> {
console.log('🔄 Resetting database and seeding...');
await this.clearAllData();
await this.seedAll();
}
}
// Export factory function
export function createSeedService(pool: Pool): SeedService {
return new SeedService(pool);
}

View File

@@ -0,0 +1,365 @@
import { Pool } from 'pg';
import pool from '../config/database';
// Simplified, unified data service that replaces the three redundant services
class UnifiedDataService {
private pool: Pool;
constructor() {
this.pool = pool;
}
// Helper to convert snake_case to camelCase
private toCamelCase(obj: any): any {
if (!obj) return obj;
if (Array.isArray(obj)) return obj.map(item => this.toCamelCase(item));
if (typeof obj !== 'object') return obj;
return Object.keys(obj).reduce((result, key) => {
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
result[camelKey] = this.toCamelCase(obj[key]);
return result;
}, {} as any);
}
// VIP Operations
async getVips() {
const query = `
SELECT v.*,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'flightNumber', f.flight_number,
'airline', f.airline,
'scheduledArrival', f.scheduled_arrival,
'scheduledDeparture', f.scheduled_departure,
'status', f.status
) ORDER BY f.scheduled_arrival
) FILTER (WHERE f.id IS NOT NULL),
'[]'
) as flights
FROM vips v
LEFT JOIN flights f ON v.id = f.vip_id
GROUP BY v.id
ORDER BY v.created_at DESC`;
const result = await this.pool.query(query);
return this.toCamelCase(result.rows);
}
async getVipById(id: string) {
const query = `
SELECT v.*,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'flightNumber', f.flight_number,
'airline', f.airline,
'scheduledArrival', f.scheduled_arrival,
'scheduledDeparture', f.scheduled_departure,
'status', f.status
) ORDER BY f.scheduled_arrival
) FILTER (WHERE f.id IS NOT NULL),
'[]'
) as flights
FROM vips v
LEFT JOIN flights f ON v.id = f.vip_id
WHERE v.id = $1
GROUP BY v.id`;
const result = await this.pool.query(query, [id]);
return this.toCamelCase(result.rows[0]);
}
async createVip(vipData: any) {
const { name, organization, department, transportMode, flights, expectedArrival,
needsAirportPickup, needsVenueTransport, notes } = vipData;
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Insert VIP
const vipQuery = `
INSERT INTO vips (name, organization, department, transport_mode, expected_arrival,
needs_airport_pickup, needs_venue_transport, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`;
const vipResult = await client.query(vipQuery, [
name, organization, department || 'Office of Development', transportMode || 'flight',
expectedArrival, needsAirportPickup !== false, needsVenueTransport !== false, notes || ''
]);
const vip = vipResult.rows[0];
// Insert flights if any
if (transportMode === 'flight' && flights?.length > 0) {
for (const flight of flights) {
await client.query(
`INSERT INTO flights (vip_id, flight_number, airline, scheduled_arrival, scheduled_departure)
VALUES ($1, $2, $3, $4, $5)`,
[vip.id, flight.flightNumber, flight.airline, flight.scheduledArrival, flight.scheduledDeparture]
);
}
}
await client.query('COMMIT');
return this.getVipById(vip.id);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async updateVip(id: string, vipData: any) {
const { name, organization, department, transportMode, flights, expectedArrival,
needsAirportPickup, needsVenueTransport, notes } = vipData;
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Update VIP
const updateQuery = `
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, updated_at = NOW()
WHERE id = $1
RETURNING *`;
const result = await client.query(updateQuery, [
id, name, organization, department, transportMode,
expectedArrival, needsAirportPickup, needsVenueTransport, notes
]);
if (result.rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
// Update flights
await client.query('DELETE FROM flights WHERE vip_id = $1', [id]);
if (transportMode === 'flight' && flights?.length > 0) {
for (const flight of flights) {
await client.query(
`INSERT INTO flights (vip_id, flight_number, airline, scheduled_arrival, scheduled_departure)
VALUES ($1, $2, $3, $4, $5)`,
[id, flight.flightNumber, flight.airline, flight.scheduledArrival, flight.scheduledDeparture]
);
}
}
await client.query('COMMIT');
return this.getVipById(id);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async deleteVip(id: string) {
const result = await this.pool.query(
'DELETE FROM vips WHERE id = $1 RETURNING *',
[id]
);
return this.toCamelCase(result.rows[0]);
}
// Driver Operations
async getDrivers() {
const result = await this.pool.query(
'SELECT * FROM drivers ORDER BY name ASC'
);
return this.toCamelCase(result.rows);
}
async getDriverById(id: string) {
const result = await this.pool.query(
'SELECT * FROM drivers WHERE id = $1',
[id]
);
return this.toCamelCase(result.rows[0]);
}
async createDriver(driverData: any) {
const { name, email, phone, vehicleInfo, status } = driverData;
const result = await this.pool.query(
`INSERT INTO drivers (name, email, phone, vehicle_info, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[name, email, phone, vehicleInfo, status || 'available']
);
return this.toCamelCase(result.rows[0]);
}
async updateDriver(id: string, driverData: any) {
const { name, email, phone, vehicleInfo, status } = driverData;
const result = await this.pool.query(
`UPDATE drivers
SET name = $2, email = $3, phone = $4, vehicle_info = $5, status = $6, updated_at = NOW()
WHERE id = $1
RETURNING *`,
[id, name, email, phone, vehicleInfo, status]
);
return this.toCamelCase(result.rows[0]);
}
async deleteDriver(id: string) {
const result = await this.pool.query(
'DELETE FROM drivers WHERE id = $1 RETURNING *',
[id]
);
return this.toCamelCase(result.rows[0]);
}
// Schedule Operations
async getScheduleByVipId(vipId: string) {
const result = await this.pool.query(
`SELECT se.*, d.name as driver_name
FROM schedule_events se
LEFT JOIN drivers d ON se.driver_id = d.id
WHERE se.vip_id = $1
ORDER BY se.event_time ASC`,
[vipId]
);
return this.toCamelCase(result.rows);
}
async createScheduleEvent(vipId: string, eventData: any) {
const { driverId, eventTime, eventType, location, notes } = eventData;
const result = await this.pool.query(
`INSERT INTO schedule_events (vip_id, driver_id, event_time, event_type, location, notes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[vipId, driverId, eventTime, eventType, location, notes]
);
return this.toCamelCase(result.rows[0]);
}
async updateScheduleEvent(id: string, eventData: any) {
const { driverId, eventTime, eventType, location, notes, status } = eventData;
const result = await this.pool.query(
`UPDATE schedule_events
SET driver_id = $2, event_time = $3, event_type = $4, location = $5,
notes = $6, status = $7, updated_at = NOW()
WHERE id = $1
RETURNING *`,
[id, driverId, eventTime, eventType, location, notes, status]
);
return this.toCamelCase(result.rows[0]);
}
async deleteScheduleEvent(id: string) {
const result = await this.pool.query(
'DELETE FROM schedule_events WHERE id = $1 RETURNING *',
[id]
);
return this.toCamelCase(result.rows[0]);
}
async getAllSchedules() {
const result = await this.pool.query(
`SELECT se.*, d.name as driver_name, v.name as vip_name
FROM schedule_events se
LEFT JOIN drivers d ON se.driver_id = d.id
LEFT JOIN vips v ON se.vip_id = v.id
ORDER BY se.event_time ASC`
);
// Group by VIP ID
const schedules: Record<string, any[]> = {};
result.rows.forEach((row: any) => {
const event = this.toCamelCase(row);
if (!schedules[event.vipId]) {
schedules[event.vipId] = [];
}
schedules[event.vipId].push(event);
});
return schedules;
}
// User Operations (simplified)
async getUserByEmail(email: string) {
const result = await this.pool.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return this.toCamelCase(result.rows[0]);
}
async getUserById(id: string) {
const result = await this.pool.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return this.toCamelCase(result.rows[0]);
}
async createUser(userData: any) {
const { email, name, role, department, googleId } = userData;
const result = await this.pool.query(
`INSERT INTO users (email, name, role, department, google_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[email, name, role || 'coordinator', department || 'Office of Development', googleId]
);
return this.toCamelCase(result.rows[0]);
}
async updateUserRole(email: string, role: string) {
const result = await this.pool.query(
`UPDATE users SET role = $2, updated_at = NOW()
WHERE email = $1
RETURNING *`,
[email, role]
);
return this.toCamelCase(result.rows[0]);
}
async getUserCount(): Promise<number> {
const result = await this.pool.query('SELECT COUNT(*) FROM users');
return parseInt(result.rows[0].count, 10);
}
// Admin Settings (simplified)
async getAdminSettings() {
const result = await this.pool.query(
'SELECT key, value FROM admin_settings'
);
return result.rows.reduce((settings: any, row: any) => {
settings[row.key] = row.value;
return settings;
}, {});
}
async updateAdminSetting(key: string, value: string) {
await this.pool.query(
`INSERT INTO admin_settings (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, value]
);
}
}
export default new UnifiedDataService();

View File

@@ -0,0 +1,264 @@
import { v4 as uuidv4 } from 'uuid';
// Test user fixtures
export const testUsers = {
admin: {
id: uuidv4(),
google_id: 'google_admin_123',
email: 'admin@test.com',
name: 'Test Admin',
role: 'administrator' as const,
status: 'active' as const,
approval_status: 'approved' as const,
profile_picture_url: 'https://example.com/admin.jpg',
organization: 'Test Org',
phone: '+1234567890',
},
coordinator: {
id: uuidv4(),
google_id: 'google_coord_456',
email: 'coordinator@test.com',
name: 'Test Coordinator',
role: 'coordinator' as const,
status: 'active' as const,
approval_status: 'approved' as const,
profile_picture_url: 'https://example.com/coord.jpg',
organization: 'Test Org',
phone: '+1234567891',
},
pendingUser: {
id: uuidv4(),
google_id: 'google_pending_789',
email: 'pending@test.com',
name: 'Pending User',
role: 'coordinator' as const,
status: 'pending' as const,
approval_status: 'pending' as const,
profile_picture_url: 'https://example.com/pending.jpg',
organization: 'Test Org',
phone: '+1234567892',
},
driver: {
id: uuidv4(),
google_id: 'google_driver_012',
email: 'driver@test.com',
name: 'Test Driver',
role: 'driver' as const,
status: 'active' as const,
approval_status: 'approved' as const,
profile_picture_url: 'https://example.com/driver.jpg',
organization: 'Test Org',
phone: '+1234567893',
},
};
// Test VIP fixtures
export const testVips = {
flightVip: {
id: uuidv4(),
name: 'John Doe',
title: 'CEO',
organization: 'Test Corp',
contact_info: '+1234567890',
arrival_datetime: new Date('2025-01-15T10:00:00Z'),
departure_datetime: new Date('2025-01-16T14:00:00Z'),
airport: 'LAX',
flight_number: 'AA123',
hotel: 'Hilton Downtown',
room_number: '1234',
status: 'scheduled' as const,
transportation_mode: 'flight' as const,
notes: 'Requires luxury vehicle',
},
drivingVip: {
id: uuidv4(),
name: 'Jane Smith',
title: 'VP Sales',
organization: 'Another Corp',
contact_info: '+0987654321',
arrival_datetime: new Date('2025-01-15T14:00:00Z'),
departure_datetime: new Date('2025-01-16T10:00:00Z'),
hotel: 'Marriott',
room_number: '567',
status: 'scheduled' as const,
transportation_mode: 'self_driving' as const,
notes: 'Arrives by personal vehicle',
},
};
// Test flight fixtures
export const testFlights = {
onTimeFlight: {
id: uuidv4(),
vip_id: testVips.flightVip.id,
flight_number: 'AA123',
airline: 'American Airlines',
scheduled_arrival: new Date('2025-01-15T10:00:00Z'),
actual_arrival: new Date('2025-01-15T10:00:00Z'),
status: 'On Time' as const,
terminal: 'Terminal 4',
gate: 'B23',
baggage_claim: 'Carousel 7',
},
delayedFlight: {
id: uuidv4(),
vip_id: uuidv4(),
flight_number: 'UA456',
airline: 'United Airlines',
scheduled_arrival: new Date('2025-01-15T12:00:00Z'),
actual_arrival: new Date('2025-01-15T13:30:00Z'),
status: 'Delayed' as const,
terminal: 'Terminal 7',
gate: 'C45',
baggage_claim: 'Carousel 3',
},
};
// Test driver fixtures
export const testDrivers = {
availableDriver: {
id: uuidv4(),
name: 'Mike Johnson',
phone: '+1234567890',
email: 'mike@drivers.com',
license_number: 'DL123456',
vehicle_info: '2023 Tesla Model S - Black',
availability_status: 'available' as const,
current_location: 'Downtown Station',
notes: 'Experienced with VIP transport',
},
busyDriver: {
id: uuidv4(),
name: 'Sarah Williams',
phone: '+0987654321',
email: 'sarah@drivers.com',
license_number: 'DL789012',
vehicle_info: '2023 Mercedes S-Class - Silver',
availability_status: 'busy' as const,
current_location: 'Airport',
notes: 'Currently on assignment',
},
};
// Test schedule event fixtures
export const testScheduleEvents = {
pickupEvent: {
id: uuidv4(),
vip_id: testVips.flightVip.id,
driver_id: testDrivers.availableDriver.id,
event_type: 'pickup' as const,
scheduled_time: new Date('2025-01-15T10:30:00Z'),
location: 'LAX Terminal 4',
status: 'scheduled' as const,
notes: 'Meet at baggage claim',
},
dropoffEvent: {
id: uuidv4(),
vip_id: testVips.flightVip.id,
driver_id: testDrivers.availableDriver.id,
event_type: 'dropoff' as const,
scheduled_time: new Date('2025-01-16T12:00:00Z'),
location: 'LAX Terminal 4',
status: 'scheduled' as const,
notes: 'Departure gate B23',
},
};
// Helper function to create test JWT payload
export function createTestJwtPayload(user: typeof testUsers.admin) {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
status: user.status,
approval_status: user.approval_status,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
};
}
// Helper function to insert test user into database
export async function insertTestUser(pool: any, user: typeof testUsers.admin) {
const query = `
INSERT INTO users (
id, google_id, email, name, role, status, approval_status,
profile_picture_url, organization, phone, created_at, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), true)
RETURNING *
`;
const values = [
user.id,
user.google_id,
user.email,
user.name,
user.role,
user.status,
user.approval_status,
user.profile_picture_url,
user.organization,
user.phone,
];
const result = await pool.query(query, values);
return result.rows[0];
}
// Helper function to insert test VIP
export async function insertTestVip(pool: any, vip: typeof testVips.flightVip) {
const query = `
INSERT INTO vips (
id, name, title, organization, contact_info, arrival_datetime,
departure_datetime, airport, flight_number, hotel, room_number,
status, transportation_mode, notes, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW())
RETURNING *
`;
const values = [
vip.id,
vip.name,
vip.title,
vip.organization,
vip.contact_info,
vip.arrival_datetime,
vip.departure_datetime,
vip.airport || null,
vip.flight_number || null,
vip.hotel,
vip.room_number,
vip.status,
vip.transportation_mode,
vip.notes,
];
const result = await pool.query(query, values);
return result.rows[0];
}
// Helper function to insert test driver
export async function insertTestDriver(pool: any, driver: typeof testDrivers.availableDriver) {
const query = `
INSERT INTO drivers (
id, name, phone, email, license_number, vehicle_info,
availability_status, current_location, notes, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
RETURNING *
`;
const values = [
driver.id,
driver.name,
driver.phone,
driver.email,
driver.license_number,
driver.vehicle_info,
driver.availability_status,
driver.current_location,
driver.notes,
];
const result = await pool.query(query, values);
return result.rows[0];
}

103
backend/src/tests/setup.ts Normal file
View File

@@ -0,0 +1,103 @@
import { Pool } from 'pg';
import { createClient } from 'redis';
import * as fs from 'fs';
import * as path from 'path';
// Test database configuration
export const testDbConfig = {
user: process.env.TEST_DB_USER || 'vip_test_user',
host: process.env.TEST_DB_HOST || 'localhost',
database: process.env.TEST_DB_NAME || 'vip_coordinator_test',
password: process.env.TEST_DB_PASSWORD || 'test_password',
port: parseInt(process.env.TEST_DB_PORT || '5432'),
};
// Test Redis configuration
export const testRedisConfig = {
url: process.env.TEST_REDIS_URL || 'redis://localhost:6380',
};
let testPool: Pool;
let testRedisClient: ReturnType<typeof createClient>;
// Setup function to initialize test database
export async function setupTestDatabase() {
testPool = new Pool(testDbConfig);
// Read and execute schema
const schemaPath = path.join(__dirname, '..', 'config', 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
try {
await testPool.query(schema);
// Run migrations
const migrationPath = path.join(__dirname, '..', 'migrations', 'add_user_management_fields.sql');
const migration = fs.readFileSync(migrationPath, 'utf8');
await testPool.query(migration);
} catch (error) {
console.error('Error setting up test database:', error);
throw error;
}
return testPool;
}
// Setup function to initialize test Redis
export async function setupTestRedis() {
testRedisClient = createClient({ url: testRedisConfig.url });
await testRedisClient.connect();
return testRedisClient;
}
// Cleanup function to clear test data
export async function cleanupTestDatabase() {
if (testPool) {
// Clear all tables in reverse order of dependencies
const tables = [
'schedule_events',
'flights',
'drivers',
'vips',
'admin_settings',
'users',
'system_setup'
];
for (const table of tables) {
await testPool.query(`TRUNCATE TABLE ${table} CASCADE`);
}
}
}
// Cleanup function for Redis
export async function cleanupTestRedis() {
if (testRedisClient && testRedisClient.isOpen) {
await testRedisClient.flushAll();
}
}
// Global setup
beforeAll(async () => {
await setupTestDatabase();
await setupTestRedis();
});
// Cleanup after each test
afterEach(async () => {
await cleanupTestDatabase();
await cleanupTestRedis();
});
// Global teardown
afterAll(async () => {
if (testPool) {
await testPool.end();
}
if (testRedisClient) {
await testRedisClient.quit();
}
});
// Export utilities for tests
export { testPool, testRedisClient };

102
backend/src/types/api.ts Normal file
View File

@@ -0,0 +1,102 @@
export interface SuccessResponse<T = any> {
success: true;
data: T;
message?: string;
timestamp: string;
}
export interface PaginatedResponse<T = any> {
success: true;
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
timestamp: string;
}
// User types
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'coordinator' | 'driver';
department?: string;
createdAt: Date;
updatedAt: Date;
}
// VIP types
export interface VIP {
id: string;
name: string;
email?: string;
phone?: string;
arrivalMode: 'flight' | 'driving';
flightNumber?: string;
arrivalTime?: Date;
departureTime?: Date;
notes?: string;
status: 'pending' | 'confirmed' | 'completed' | 'cancelled';
createdAt: Date;
updatedAt: Date;
}
// Driver types
export interface Driver {
id: string;
name: string;
email?: string;
phone: string;
vehicleInfo?: string;
status: 'available' | 'assigned' | 'unavailable';
createdAt: Date;
updatedAt: Date;
}
// Schedule Event types
export interface ScheduleEvent {
id: string;
vipId: string;
driverId?: string;
eventType: 'pickup' | 'dropoff' | 'custom';
eventTime: Date;
location: string;
notes?: string;
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
createdAt: Date;
updatedAt: Date;
}
// Request types
export interface AuthRequest extends Request {
user?: User;
requestId?: string;
}
// Response helper functions
export const successResponse = <T>(data: T, message?: string): SuccessResponse<T> => ({
success: true,
data,
message,
timestamp: new Date().toISOString()
});
export const paginatedResponse = <T>(
data: T[],
page: number,
limit: number,
total: number
): PaginatedResponse<T> => ({
success: true,
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
},
timestamp: new Date().toISOString()
});

View File

@@ -0,0 +1,59 @@
export class AppError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean;
constructor(message: string, statusCode: number, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400, true);
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Authentication failed') {
super(message, 401, true);
}
}
export class AuthorizationError extends AppError {
constructor(message = 'Insufficient permissions') {
super(message, 403, true);
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, 404, true);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, true);
}
}
export class DatabaseError extends AppError {
constructor(message = 'Database operation failed') {
super(message, 500, false);
}
}
export interface ErrorResponse {
success: false;
error: {
message: string;
code?: string;
details?: any;
};
timestamp: string;
path?: string;
}

View File

@@ -0,0 +1,122 @@
import { z } from 'zod';
// Common schemas
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
const emailSchema = z.string().email().optional();
const phoneSchema = z.string().regex(phoneRegex, 'Invalid phone number format').optional();
// VIP schemas
export const vipFlightSchema = z.object({
flightNumber: z.string().min(1, 'Flight number is required'),
airline: z.string().optional(),
scheduledArrival: z.string().datetime().or(z.date()),
scheduledDeparture: z.string().datetime().or(z.date()).optional(),
status: z.enum(['scheduled', 'delayed', 'cancelled', 'arrived']).optional()
});
export const createVipSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
organization: z.string().max(100).optional(),
department: z.enum(['Office of Development', 'Admin']).default('Office of Development'),
transportMode: z.enum(['flight', 'self-driving']).default('flight'),
flights: z.array(vipFlightSchema).optional(),
expectedArrival: z.string().datetime().or(z.date()).optional(),
needsAirportPickup: z.boolean().default(true),
needsVenueTransport: z.boolean().default(true),
notes: z.string().max(500).optional()
}).refine(
(data) => {
if (data.transportMode === 'flight' && (!data.flights || data.flights.length === 0)) {
return false;
}
if (data.transportMode === 'self-driving' && !data.expectedArrival) {
return false;
}
return true;
},
{
message: 'Flight mode requires at least one flight, self-driving requires expected arrival'
}
);
export const updateVipSchema = z.object({
name: z.string().min(1, 'Name is required').max(100).optional(),
organization: z.string().max(100).optional(),
department: z.enum(['Office of Development', 'Admin']).optional(),
transportMode: z.enum(['flight', 'self-driving']).optional(),
flights: z.array(vipFlightSchema).optional(),
expectedArrival: z.string().datetime().or(z.date()).optional(),
needsAirportPickup: z.boolean().optional(),
needsVenueTransport: z.boolean().optional(),
notes: z.string().max(500).optional()
});
// Driver schemas
export const createDriverSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: emailSchema,
phone: z.string().regex(phoneRegex, 'Invalid phone number format'),
vehicleInfo: z.string().max(200).optional(),
status: z.enum(['available', 'assigned', 'unavailable']).default('available')
});
export const updateDriverSchema = createDriverSchema.partial();
// Schedule Event schemas
export const createScheduleEventSchema = z.object({
vipId: z.string().uuid('Invalid VIP ID'),
driverId: z.string().uuid('Invalid driver ID').optional(),
eventType: z.enum(['pickup', 'dropoff', 'custom']),
eventTime: z.string().datetime().or(z.date()),
location: z.string().min(1, 'Location is required').max(200),
notes: z.string().max(500).optional(),
status: z.enum(['scheduled', 'in_progress', 'completed', 'cancelled']).default('scheduled')
});
export const updateScheduleEventSchema = createScheduleEventSchema.partial();
// User schemas
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(1, 'Name is required').max(100),
role: z.enum(['admin', 'coordinator', 'driver']),
department: z.string().max(100).optional(),
password: z.string().min(8, 'Password must be at least 8 characters').optional()
});
export const updateUserSchema = createUserSchema.partial();
// Admin settings schemas
export const updateAdminSettingsSchema = z.object({
key: z.string().min(1, 'Key is required'),
value: z.string(),
description: z.string().optional()
});
// Auth schemas
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required')
});
export const googleAuthCallbackSchema = z.object({
code: z.string().min(1, 'Authorization code is required')
});
// Query parameter schemas
export const paginationSchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).default('1'),
limit: z.string().regex(/^\d+$/).transform(Number).default('20'),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).default('asc')
});
export const dateRangeSchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional()
});
// Route parameter schemas
export const idParamSchema = z.object({
id: z.string().min(1, 'ID is required')
});