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:
@@ -15,7 +15,32 @@ CMD ["npm", "run", "dev"]
|
||||
|
||||
# Production stage
|
||||
FROM base AS production
|
||||
|
||||
# Install dependencies (including dev dependencies for build)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npx tsc --version && npx tsc
|
||||
|
||||
# Remove dev dependencies to reduce image size
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
# Change ownership of the app directory
|
||||
RUN chown -R nodejs:nodejs /app
|
||||
USER nodejs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
# Start the production server
|
||||
CMD ["npm", "start"]
|
||||
|
||||
23
backend/jest.config.js
Normal file
23
backend/jest.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.test.ts',
|
||||
'!src/**/*.spec.ts',
|
||||
'!src/types/**',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
setupFilesAfterEnv: ['<rootDir>/src/tests/setup.ts'],
|
||||
testTimeout: 30000,
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
};
|
||||
2610
backend/package-lock.json
generated
2610
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
backend/src/config/env.ts
Normal file
57
backend/src/config/env.ts
Normal 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>;
|
||||
177
backend/src/config/mockDatabase.ts
Normal file
177
backend/src/config/mockDatabase.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
878
backend/src/index.original.ts
Normal file
878
backend/src/index.original.ts
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
263
backend/src/indexSimplified.ts
Normal file
263
backend/src/indexSimplified.ts
Normal 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`);
|
||||
});
|
||||
78
backend/src/middleware/errorHandler.ts
Normal file
78
backend/src/middleware/errorHandler.ts
Normal 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);
|
||||
};
|
||||
88
backend/src/middleware/logger.ts
Normal file
88
backend/src/middleware/logger.ts
Normal 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);
|
||||
};
|
||||
93
backend/src/middleware/simpleValidation.ts
Normal file
93
backend/src/middleware/simpleValidation.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
75
backend/src/middleware/validation.ts
Normal file
75
backend/src/middleware/validation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
114
backend/src/migrations/add_user_management_fields.sql
Normal file
114
backend/src/migrations/add_user_management_fields.sql
Normal 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;
|
||||
309
backend/src/routes/__tests__/vips.test.ts
Normal file
309
backend/src/routes/__tests__/vips.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
55
backend/src/scripts/check-and-fix-users.sql
Normal file
55
backend/src/scripts/check-and-fix-users.sql
Normal 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;
|
||||
126
backend/src/scripts/db-cli.ts
Normal file
126
backend/src/scripts/db-cli.ts
Normal 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);
|
||||
85
backend/src/scripts/fix-existing-user-admin.js
Normal file
85
backend/src/scripts/fix-existing-user-admin.js
Normal 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();
|
||||
77
backend/src/scripts/fix-first-admin-docker.js
Normal file
77
backend/src/scripts/fix-first-admin-docker.js
Normal 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();
|
||||
66
backend/src/scripts/fix-first-admin.js
Normal file
66
backend/src/scripts/fix-first-admin.js
Normal 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();
|
||||
102
backend/src/scripts/fix-specific-user-admin.js
Normal file
102
backend/src/scripts/fix-specific-user-admin.js
Normal 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();
|
||||
249
backend/src/services/__tests__/authService.test.ts
Normal file
249
backend/src/services/__tests__/authService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
197
backend/src/services/authService.ts
Normal file
197
backend/src/services/authService.ts
Normal 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();
|
||||
@@ -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'}`);
|
||||
|
||||
180
backend/src/services/migrationService.ts
Normal file
180
backend/src/services/migrationService.ts
Normal 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;
|
||||
}
|
||||
285
backend/src/services/seedService.ts
Normal file
285
backend/src/services/seedService.ts
Normal 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);
|
||||
}
|
||||
365
backend/src/services/unifiedDataService.ts
Normal file
365
backend/src/services/unifiedDataService.ts
Normal 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();
|
||||
264
backend/src/tests/fixtures.ts
Normal file
264
backend/src/tests/fixtures.ts
Normal 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
103
backend/src/tests/setup.ts
Normal 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
102
backend/src/types/api.ts
Normal 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()
|
||||
});
|
||||
59
backend/src/types/errors.ts
Normal file
59
backend/src/types/errors.ts
Normal 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;
|
||||
}
|
||||
122
backend/src/types/schemas.ts
Normal file
122
backend/src/types/schemas.ts
Normal 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')
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
@@ -12,7 +12,9 @@
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"types": ["node"],
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user