Initial commit

This commit is contained in:
2026-04-28 22:51:54 +03:00
commit 3565390a49
76 changed files with 16956 additions and 0 deletions

24
backend/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm i --only=production
# Copy application files
COPY . .
# Create logs directory
RUN mkdir -p logs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
CMD ["npm", "start"]

View File

@@ -0,0 +1,187 @@
-- Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
-- API Keys table
CREATE TABLE api_keys (
id SERIAL PRIMARY KEY,
key VARCHAR(64) UNIQUE NOT NULL,
app_name VARCHAR(100) NOT NULL,
rate_limit INTEGER DEFAULT 1000,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP
);
-- Routes table (маршруты)
CREATE TABLE routes (
id SERIAL PRIMARY KEY,
route_number VARCHAR(10) NOT NULL,
name VARCHAR(200) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('bus', 'minibus', 'trolleybus', 'tram')),
color VARCHAR(7) DEFAULT '#0066CC',
is_active BOOLEAN DEFAULT true,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Stops table (остановки) with geospatial support
CREATE TABLE stops (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
location GEOGRAPHY(Point, 4326) NOT NULL,
address VARCHAR(300),
description TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create spatial index for stops
CREATE INDEX idx_stops_location ON stops USING GIST(location);
-- Route stops junction table (связь маршрутов и остановок)
CREATE TABLE route_stops (
id SERIAL PRIMARY KEY,
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE CASCADE,
sequence INTEGER NOT NULL,
time_offset_minutes INTEGER NOT NULL DEFAULT 0,
UNIQUE(route_id, stop_id, sequence)
);
-- Schedules table (расписание)
CREATE TABLE schedules (
id SERIAL PRIMARY KEY,
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
day_type VARCHAR(20) NOT NULL CHECK (day_type IN ('weekday', 'saturday', 'sunday', 'holiday')),
departure_times TIME[] NOT NULL,
valid_from DATE,
valid_until DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Vehicles table (транспортные средства)
CREATE TABLE vehicles (
id SERIAL PRIMARY KEY,
vehicle_number VARCHAR(20) NOT NULL,
registration VARCHAR(20),
type VARCHAR(20) NOT NULL,
capacity INTEGER,
route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Historical delays table (статистика задержек)
CREATE TABLE historical_delays (
id SERIAL PRIMARY KEY,
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE CASCADE,
hour_of_day INTEGER NOT NULL CHECK (hour_of_day BETWEEN 0 AND 23),
day_type VARCHAR(20) NOT NULL,
avg_delay_minutes INTEGER NOT NULL,
sample_count INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(route_id, stop_id, hour_of_day, day_type)
);
-- Alerts table (уведомления и оповещения)
CREATE TABLE alerts (
id SERIAL PRIMARY KEY,
route_id INTEGER REFERENCES routes(id) ON DELETE CASCADE,
alert_type VARCHAR(20) NOT NULL CHECK (alert_type IN ('delay', 'cancellation', 'detour', 'info')),
title VARCHAR(200) NOT NULL,
message TEXT NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Sync log table (для offline синхронизации)
CREATE TABLE sync_log (
id SERIAL PRIMARY KEY,
table_name VARCHAR(50) NOT NULL,
record_id INTEGER NOT NULL,
action VARCHAR(20) NOT NULL CHECK (action IN ('insert', 'update', 'delete')),
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes
CREATE INDEX idx_routes_number ON routes(route_number);
CREATE INDEX idx_routes_active ON routes(is_active);
CREATE INDEX idx_stops_name ON stops(name);
CREATE INDEX idx_route_stops_route ON route_stops(route_id);
CREATE INDEX idx_route_stops_stop ON route_stops(stop_id);
CREATE INDEX idx_schedules_route ON schedules(route_id);
CREATE INDEX idx_vehicles_route ON vehicles(route_id);
CREATE INDEX idx_alerts_active ON alerts(is_active, start_time, end_time);
CREATE INDEX idx_sync_log_timestamp ON sync_log(timestamp);
-- Create function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create triggers for updated_at
CREATE TRIGGER update_routes_updated_at BEFORE UPDATE ON routes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_stops_updated_at BEFORE UPDATE ON stops
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_schedules_updated_at BEFORE UPDATE ON schedules
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_vehicles_updated_at BEFORE UPDATE ON vehicles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Create function to log changes for sync
CREATE OR REPLACE FUNCTION log_sync_changes()
RETURNS TRIGGER AS $$
BEGIN
IF (TG_OP = 'DELETE') THEN
INSERT INTO sync_log (table_name, record_id, action)
VALUES (TG_TABLE_NAME, OLD.id, 'delete');
RETURN OLD;
ELSIF (TG_OP = 'UPDATE') THEN
INSERT INTO sync_log (table_name, record_id, action)
VALUES (TG_TABLE_NAME, NEW.id, 'update');
RETURN NEW;
ELSIF (TG_OP = 'INSERT') THEN
INSERT INTO sync_log (table_name, record_id, action)
VALUES (TG_TABLE_NAME, NEW.id, 'insert');
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Create sync triggers for main tables
CREATE TRIGGER sync_routes AFTER INSERT OR UPDATE OR DELETE ON routes
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
CREATE TRIGGER sync_stops AFTER INSERT OR UPDATE OR DELETE ON stops
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
CREATE TRIGGER sync_route_stops AFTER INSERT OR UPDATE OR DELETE ON route_stops
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
CREATE TRIGGER sync_schedules AFTER INSERT OR UPDATE OR DELETE ON schedules
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
-- Insert sample API key for development
INSERT INTO api_keys (key, app_name, rate_limit)
VALUES ('dev_key_12345678', 'Development App', 10000);
-- Grant permissions
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO transit_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO transit_admin;

View File

@@ -0,0 +1,86 @@
-- Holidays table (праздничные дни Беларуси)
CREATE TABLE IF NOT EXISTS holidays (
id SERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
is_recurring BOOLEAN DEFAULT false, -- повторяется каждый год
recurring_month INTEGER, -- месяц для повторяющихся
recurring_day INTEGER, -- день для повторяющихся
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for fast date lookup
CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date);
-- Insert Belarus official holidays (recurring)
-- Эти праздники повторяются каждый год в одну и ту же дату
INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day) VALUES
('2025-01-01', 'Новый год', true, 1, 1),
('2025-01-07', 'Рождество (православное)', true, 1, 7),
('2025-03-08', 'День женщин', true, 3, 8),
('2025-05-01', 'Праздник труда', true, 5, 1),
('2025-05-09', 'День Победы', true, 5, 9),
('2025-07-03', 'День Независимости', true, 7, 3),
('2025-11-07', 'День Октябрьской революции', true, 11, 7),
('2025-12-25', 'Рождество (католическое)', true, 12, 25)
ON CONFLICT (date) DO NOTHING;
-- Радуница (переходящая дата) - нужно добавлять вручную каждый год
-- 2025: 29 апреля
-- 2026: 21 апреля
INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day) VALUES
('2025-04-29', 'Радуница', false, NULL, NULL),
('2026-04-21', 'Радуница', false, NULL, NULL)
ON CONFLICT (date) DO NOTHING;
-- Function to check if a date is a holiday
CREATE OR REPLACE FUNCTION is_holiday(check_date DATE)
RETURNS BOOLEAN AS $$
BEGIN
-- Check exact date match
IF EXISTS (SELECT 1 FROM holidays WHERE date = check_date) THEN
RETURN TRUE;
END IF;
-- Check recurring holidays (for future years)
IF EXISTS (
SELECT 1 FROM holidays
WHERE is_recurring = true
AND recurring_month = EXTRACT(MONTH FROM check_date)
AND recurring_day = EXTRACT(DAY FROM check_date)
) THEN
RETURN TRUE;
END IF;
RETURN FALSE;
END;
$$ LANGUAGE plpgsql;
-- Function to get schedule type for a date
-- Returns: 'weekday', 'saturday', 'sunday', or 'holiday'
CREATE OR REPLACE FUNCTION get_schedule_type(check_date DATE)
RETURNS VARCHAR(20) AS $$
DECLARE
day_of_week INTEGER;
BEGIN
-- Check if it's a holiday first (holidays use sunday schedule)
IF is_holiday(check_date) THEN
RETURN 'holiday';
END IF;
-- Get day of week (0 = Sunday, 6 = Saturday)
day_of_week := EXTRACT(DOW FROM check_date);
IF day_of_week = 0 THEN
RETURN 'sunday';
ELSIF day_of_week = 6 THEN
RETURN 'saturday';
ELSE
RETURN 'weekday';
END IF;
END;
$$ LANGUAGE plpgsql;
-- Add sync trigger for holidays
CREATE TRIGGER sync_holidays AFTER INSERT OR UPDATE OR DELETE ON holidays
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();

View File

@@ -0,0 +1,44 @@
-- Users table for authentication
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(100),
email VARCHAR(100),
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')) DEFAULT 'user',
is_active BOOLEAN DEFAULT true,
last_login TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_role ON users(role);
-- Update trigger
CREATE OR REPLACE FUNCTION update_users_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_users_updated_at();
-- Sessions table
CREATE TABLE user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
refresh_token VARCHAR(500) NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_sessions_user_id ON user_sessions(user_id);
CREATE INDEX idx_sessions_token ON user_sessions(refresh_token);

View File

@@ -0,0 +1,7 @@
-- Add avatar_url column to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(255);
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_users_avatar ON users(avatar_url);
COMMENT ON COLUMN users.avatar_url IS 'URL or path to user avatar image';

View File

@@ -0,0 +1,57 @@
-- Realtime GPS telemetry storage
CREATE TABLE IF NOT EXISTS telemetry_samples (
id BIGSERIAL PRIMARY KEY,
vehicle_id INTEGER REFERENCES vehicles(id) ON DELETE SET NULL,
route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL,
source_type VARCHAR(30) NOT NULL DEFAULT 'manual',
source_ref VARCHAR(120),
location GEOGRAPHY(Point, 4326) NOT NULL,
speed_kmh NUMERIC(6, 2),
heading NUMERIC(6, 2),
accuracy_meters NUMERIC(6, 2),
recorded_at TIMESTAMP NOT NULL,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_valid BOOLEAN DEFAULT true,
raw_payload JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_telemetry_samples_vehicle_id ON telemetry_samples(vehicle_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_samples_route_id ON telemetry_samples(route_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_samples_recorded_at ON telemetry_samples(recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_telemetry_samples_location ON telemetry_samples USING GIST(location);
-- Latest live position for each vehicle
CREATE TABLE IF NOT EXISTS vehicle_live_state (
vehicle_id INTEGER PRIMARY KEY REFERENCES vehicles(id) ON DELETE CASCADE,
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
location GEOGRAPHY(Point, 4326) NOT NULL,
speed_kmh NUMERIC(6, 2),
heading NUMERIC(6, 2),
accuracy_meters NUMERIC(6, 2),
source_type VARCHAR(30) NOT NULL DEFAULT 'manual',
source_ref VARCHAR(120),
last_seen_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_route_id ON vehicle_live_state(route_id);
CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_last_seen_at ON vehicle_live_state(last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_location ON vehicle_live_state USING GIST(location);
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_proc
WHERE proname = 'update_updated_at_column'
) AND NOT EXISTS (
SELECT 1
FROM pg_trigger
WHERE tgname = 'update_vehicle_live_state_updated_at'
) THEN
CREATE TRIGGER update_vehicle_live_state_updated_at
BEFORE UPDATE ON vehicle_live_state
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;

40
backend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "polotsk-transit-api",
"version": "1.0.0",
"description": "Polotsk Public Transit API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"migrate": "node src/utils/migrate.js",
"seed": "node src/utils/seed.js"
},
"keywords": [
"transit",
"api",
"polotsk"
],
"author": "Zakhar",
"license": "MIT",
"dependencies": {
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"pg": "^8.11.3",
"redis": "^4.6.10",
"socket.io": "^4.7.2",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"ws": "^8.20.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View File

@@ -0,0 +1,29 @@
const bcrypt = require('bcrypt');
const pool = require('../src/config/database');
async function createAdmin() {
const username = 'admin';
const password = 'admin123'; // Измените после первого входа!
const passwordHash = await bcrypt.hash(password, 10);
try {
const result = await pool.query(`
INSERT INTO users (username, password_hash, full_name, email, role, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (username) DO UPDATE
SET password_hash = $2
RETURNING id, username, role
`, [username, passwordHash, 'Главный администратор', 'admin@polotsk-transit.local', 'admin', true]);
console.log('✅ Мастер-аккаунт создан:', result.rows[0]);
console.log('Username: admin');
console.log('Password: admin123');
console.log('⚠️ ОБЯЗАТЕЛЬНО смените пароль после первого входа!');
} catch (error) {
console.error('❌ Ошибка:', error);
} finally {
await pool.end();
}
}
createAdmin();

View File

@@ -0,0 +1,25 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
process.exit(-1);
});
// Test connection
pool.query('SELECT NOW()', (err, res) => {
if (err) {
console.error('Database connection failed:', err);
} else {
console.log('Database connected successfully');
}
});
module.exports = pool;

View File

@@ -0,0 +1,36 @@
const winston = require('winston');
const path = require('path');
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
defaultMeta: { service: 'polotsk-transit-api' },
transports: [
// Write all logs with level 'error' and below to error.log
new winston.transports.File({
filename: path.join(__dirname, '../../logs/error.log'),
level: 'error'
}),
// Write all logs with level 'info' and below to combined.log
new winston.transports.File({
filename: path.join(__dirname, '../../logs/combined.log')
})
]
});
// If we're not in production, log to console as well
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;

View File

@@ -0,0 +1,21 @@
const redis = require('redis');
require('dotenv').config();
const client = redis.createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
client.on('error', (err) => {
console.error('Redis Client Error:', err);
});
client.on('connect', () => {
console.log('Redis connected successfully');
});
// Connect to Redis
(async () => {
await client.connect();
})();
module.exports = client;

View File

@@ -0,0 +1,232 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const pool = require('../config/database');
const logger = require('../config/logger');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const JWT_EXPIRES_IN = '24h';
const REFRESH_TOKEN_EXPIRES_DAYS = 7;
class AuthController {
// Login
async login(req, res) {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
// Find user
const userResult = await pool.query(
'SELECT * FROM users WHERE username = $1 AND is_active = true',
[username]
);
if (userResult.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = userResult.rows[0];
// Verify password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const accessToken = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ id: user.id, type: 'refresh' },
JWT_SECRET,
{ expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d` }
);
// Save refresh token
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
await pool.query(
`INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5)`,
[user.id, refreshToken, expiresAt, req.ip, req.get('user-agent')]
);
// Update last login
await pool.query(
'UPDATE users SET last_login = NOW() WHERE id = $1',
[user.id]
);
// Remove password hash from response
delete user.password_hash;
logger.info(`User logged in: ${username}`);
res.json({
user,
accessToken,
refreshToken
});
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Refresh token
async refresh(req, res) {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
// Verify refresh token
let decoded;
try {
decoded = jwt.verify(refreshToken, JWT_SECRET);
} catch (error) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Check if session exists and not expired
const sessionResult = await pool.query(
`SELECT s.*, u.username, u.role, u.is_active
FROM user_sessions s
JOIN users u ON s.user_id = u.id
WHERE s.refresh_token = $1 AND s.expires_at > NOW()`,
[refreshToken]
);
if (sessionResult.rows.length === 0) {
return res.status(401).json({ error: 'Session expired or invalid' });
}
const session = sessionResult.rows[0];
if (!session.is_active) {
return res.status(401).json({ error: 'User is inactive' });
}
// Generate new access token
const accessToken = jwt.sign(
{ id: session.user_id, username: session.username, role: session.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
res.json({ accessToken });
} catch (error) {
logger.error('Token refresh error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Logout
async logout(req, res) {
try {
const { refreshToken } = req.body;
if (refreshToken) {
await pool.query(
'DELETE FROM user_sessions WHERE refresh_token = $1',
[refreshToken]
);
}
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Logout error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Get current user
async me(req, res) {
try {
const userResult = await pool.query(
'SELECT id, username, full_name, email, role, avatar_url, last_login, created_at FROM users WHERE id = $1',
[req.user.id]
);
if (userResult.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(userResult.rows[0]);
} catch (error) {
logger.error('Get user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Change password
async changePassword(req, res) {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Current and new password required' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
// Get user
const userResult = await pool.query(
'SELECT password_hash FROM users WHERE id = $1',
[req.user.id]
);
if (userResult.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const validPassword = await bcrypt.compare(
currentPassword,
userResult.rows[0].password_hash
);
if (!validPassword) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
// Hash new password
const newPasswordHash = await bcrypt.hash(newPassword, 10);
// Update password
await pool.query(
'UPDATE users SET password_hash = $1 WHERE id = $2',
[newPasswordHash, req.user.id]
);
// Invalidate all sessions except current
const { refreshToken } = req.body;
if (refreshToken) {
await pool.query(
'DELETE FROM user_sessions WHERE user_id = $1 AND refresh_token != $2',
[req.user.id, refreshToken]
);
}
logger.info(`Password changed for user: ${req.user.username}`);
res.json({ message: 'Password changed successfully' });
} catch (error) {
logger.error('Change password error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
}
module.exports = new AuthController();

View File

@@ -0,0 +1,271 @@
const pool = require('../config/database');
const logger = require('../config/logger');
class EtaController {
// Calculate ETA for a stop on a route
async calculateEta(req, res) {
const { stopId, routeId } = req.body;
const currentTime = new Date();
if (!stopId || !routeId) {
return res.status(400).json({
error: 'Missing required fields',
required: ['stopId', 'routeId']
});
}
try {
// Get schedule type for today (holiday-aware)
const typeResult = await pool.query(
'SELECT get_schedule_type(CURRENT_DATE) as schedule_type'
);
let scheduleType = typeResult.rows[0].schedule_type;
// Get schedule for this route
let scheduleResult = await pool.query(`
SELECT departure_times
FROM schedules
WHERE route_id = $1
AND day_type = $2
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
LIMIT 1
`, [routeId, scheduleType]);
// If holiday and no specific holiday schedule, fall back to sunday
if (scheduleResult.rows.length === 0 && scheduleType === 'holiday') {
scheduleType = 'sunday';
scheduleResult = await pool.query(`
SELECT departure_times
FROM schedules
WHERE route_id = $1
AND day_type = 'sunday'
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
LIMIT 1
`, [routeId]);
}
if (scheduleResult.rows.length === 0) {
return res.status(404).json({
error: 'No schedule found',
message: 'Schedule not available for this route and day type',
scheduleType: scheduleType
});
}
const departureTimes = scheduleResult.rows[0].departure_times;
// Get time offset for this stop
const stopResult = await pool.query(`
SELECT time_offset_minutes
FROM route_stops
WHERE route_id = $1 AND stop_id = $2
`, [routeId, stopId]);
if (stopResult.rows.length === 0) {
return res.status(404).json({
error: 'Stop not found on route',
message: 'This stop is not part of this route'
});
}
const timeOffset = stopResult.rows[0].time_offset_minutes;
// Calculate ETAs
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
const etas = [];
for (const departureTime of departureTimes) {
if (departureTime > currentTimeStr) {
// Add time offset to get arrival time at this stop
const arrival = this.addMinutesToTime(departureTime, timeOffset);
// Get historical delay for this hour and day type
const delay = await this.getHistoricalDelay(
routeId,
stopId,
currentTime.getHours(),
scheduleType === 'holiday' ? 'sunday' : scheduleType
);
const adjustedArrival = this.addMinutesToTime(arrival, delay);
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
etas.push({
scheduledTime: arrival,
estimatedTime: adjustedArrival,
minutesUntil: minutesUntil,
delay: delay
});
// Return next 3 arrivals
if (etas.length >= 3) break;
}
}
if (etas.length === 0) {
return res.json({
message: 'No more arrivals today',
nextDay: departureTimes[0]
});
}
res.json({
routeId,
stopId,
currentTime: currentTimeStr,
dayType: scheduleType,
arrivals: etas
});
} catch (error) {
logger.error('Error calculating ETA:', error);
res.status(500).json({ error: 'Failed to calculate ETA' });
}
}
// Get multiple ETAs for a stop (all routes)
async getStopEtas(req, res) {
const { stopId } = req.params;
try {
// Get all routes passing through this stop
const routesResult = await pool.query(`
SELECT DISTINCT r.id, r.route_number, r.name, r.type, r.color
FROM routes r
JOIN route_stops rs ON r.id = rs.route_id
WHERE rs.stop_id = $1 AND r.is_active = true
`, [stopId]);
const etaPromises = routesResult.rows.map(route =>
this.calculateEtaForRoute(stopId, route.id)
.then(etas => ({
...route,
arrivals: etas
}))
.catch(err => {
logger.error(`Error calculating ETA for route ${route.id}:`, err);
return {
...route,
arrivals: [],
error: 'Could not calculate ETA'
};
})
);
const results = await Promise.all(etaPromises);
res.json({
stopId,
routes: results
});
} catch (error) {
logger.error('Error getting stop ETAs:', error);
res.status(500).json({ error: 'Failed to get stop ETAs' });
}
}
// Helper: Calculate ETA for a specific route (internal use)
async calculateEtaForRoute(stopId, routeId) {
const currentTime = new Date();
const dayType = this.getDayType(currentTime);
const scheduleResult = await pool.query(`
SELECT departure_times
FROM schedules
WHERE route_id = $1 AND day_type = $2
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
LIMIT 1
`, [routeId, dayType]);
if (scheduleResult.rows.length === 0) return [];
const departureTimes = scheduleResult.rows[0].departure_times;
const stopResult = await pool.query(`
SELECT time_offset_minutes
FROM route_stops
WHERE route_id = $1 AND stop_id = $2
`, [routeId, stopId]);
if (stopResult.rows.length === 0) return [];
const timeOffset = stopResult.rows[0].time_offset_minutes;
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
const etas = [];
for (const departureTime of departureTimes) {
if (departureTime > currentTimeStr) {
const arrival = this.addMinutesToTime(departureTime, timeOffset);
const delay = await this.getHistoricalDelay(
routeId,
stopId,
currentTime.getHours(),
dayType
);
const adjustedArrival = this.addMinutesToTime(arrival, delay);
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
etas.push({
scheduledTime: arrival,
estimatedTime: adjustedArrival,
minutesUntil: minutesUntil,
delay: delay
});
if (etas.length >= 3) break;
}
}
return etas;
}
// Helper: Get historical delay
async getHistoricalDelay(routeId, stopId, hour, dayType) {
try {
const result = await pool.query(`
SELECT avg_delay_minutes
FROM historical_delays
WHERE route_id = $1
AND stop_id = $2
AND hour_of_day = $3
AND day_type = $4
`, [routeId, stopId, hour, dayType]);
return result.rows.length > 0 ? result.rows[0].avg_delay_minutes : 0;
} catch (error) {
logger.error('Error fetching historical delay:', error);
return 0;
}
}
// Helper: Determine day type
getDayType(date) {
const day = date.getDay();
if (day === 0) return 'sunday';
if (day === 6) return 'saturday';
return 'weekday';
}
// Helper: Add minutes to time string
addMinutesToTime(timeStr, minutes) {
const [hours, mins, secs] = timeStr.split(':').map(Number);
const totalMins = hours * 60 + mins + minutes;
const newHours = Math.floor(totalMins / 60) % 24;
const newMins = totalMins % 60;
return `${String(newHours).padStart(2, '0')}:${String(newMins).padStart(2, '0')}:${String(secs || 0).padStart(2, '0')}`;
}
// Helper: Get minutes difference between two times
getMinutesDifference(time1, time2) {
const [h1, m1] = time1.split(':').map(Number);
const [h2, m2] = time2.split(':').map(Number);
return (h2 * 60 + m2) - (h1 * 60 + m1);
}
}
module.exports = new EtaController();

View File

@@ -0,0 +1,223 @@
const pool = require('../config/database');
const redisClient = require('../config/redis');
const logger = require('../config/logger');
class HolidaysController {
// Get all holidays
async getAllHolidays(req, res) {
const { year } = req.query;
try {
let query = `
SELECT * FROM holidays
ORDER BY date
`;
let params = [];
if (year) {
query = `
SELECT * FROM holidays
WHERE EXTRACT(YEAR FROM date) = $1
OR is_recurring = true
ORDER BY
CASE WHEN EXTRACT(YEAR FROM date) = $1 THEN date
ELSE MAKE_DATE($1::int, recurring_month, recurring_day)
END
`;
params = [year];
}
const result = await pool.query(query, params);
res.json(result.rows);
} catch (error) {
logger.error('Error fetching holidays:', error);
res.status(500).json({ error: 'Failed to fetch holidays' });
}
}
// Get holidays for a specific year (with resolved recurring dates)
async getHolidaysForYear(req, res) {
const { year } = req.params;
try {
const result = await pool.query(`
SELECT
id,
CASE
WHEN is_recurring THEN MAKE_DATE($1::int, recurring_month, recurring_day)
ELSE date
END as date,
name,
is_recurring
FROM holidays
WHERE EXTRACT(YEAR FROM date) = $1
OR is_recurring = true
ORDER BY date
`, [year]);
res.json(result.rows);
} catch (error) {
logger.error('Error fetching holidays for year:', error);
res.status(500).json({ error: 'Failed to fetch holidays' });
}
}
// Check if a specific date is a holiday
async checkHoliday(req, res) {
const { date } = req.params;
try {
const result = await pool.query(
'SELECT is_holiday($1::date) as is_holiday',
[date]
);
const holidayInfo = await pool.query(`
SELECT name FROM holidays
WHERE date = $1
OR (is_recurring = true
AND recurring_month = EXTRACT(MONTH FROM $1::date)
AND recurring_day = EXTRACT(DAY FROM $1::date))
LIMIT 1
`, [date]);
res.json({
date: date,
isHoliday: result.rows[0].is_holiday,
holidayName: holidayInfo.rows[0]?.name || null
});
} catch (error) {
logger.error('Error checking holiday:', error);
res.status(500).json({ error: 'Failed to check holiday' });
}
}
// Add a holiday
async createHoliday(req, res) {
const { date, name, isRecurring } = req.body;
if (!date || !name) {
return res.status(400).json({
error: 'Missing required fields',
required: ['date', 'name']
});
}
try {
const dateObj = new Date(date);
const month = dateObj.getMonth() + 1;
const day = dateObj.getDate();
const result = await pool.query(`
INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (date) DO UPDATE SET name = $2
RETURNING *
`, [date, name, isRecurring || false, isRecurring ? month : null, isRecurring ? day : null]);
logger.info(`Holiday created: ${name} on ${date}`);
res.status(201).json(result.rows[0]);
} catch (error) {
logger.error('Error creating holiday:', error);
res.status(500).json({ error: 'Failed to create holiday' });
}
}
// Update a holiday
async updateHoliday(req, res) {
const { id } = req.params;
const { date, name, isRecurring } = req.body;
try {
let month = null;
let day = null;
if (date && isRecurring) {
const dateObj = new Date(date);
month = dateObj.getMonth() + 1;
day = dateObj.getDate();
}
const result = await pool.query(`
UPDATE holidays
SET
date = COALESCE($1, date),
name = COALESCE($2, name),
is_recurring = COALESCE($3, is_recurring),
recurring_month = $4,
recurring_day = $5
WHERE id = $6
RETURNING *
`, [date, name, isRecurring, month, day, id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Holiday not found' });
}
logger.info(`Holiday ${id} updated`);
res.json(result.rows[0]);
} catch (error) {
logger.error('Error updating holiday:', error);
res.status(500).json({ error: 'Failed to update holiday' });
}
}
// Delete a holiday
async deleteHoliday(req, res) {
const { id } = req.params;
try {
const result = await pool.query(
'DELETE FROM holidays WHERE id = $1 RETURNING *',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Holiday not found' });
}
logger.info(`Holiday ${id} deleted`);
res.json({ message: 'Holiday deleted successfully' });
} catch (error) {
logger.error('Error deleting holiday:', error);
res.status(500).json({ error: 'Failed to delete holiday' });
}
}
// Get upcoming holidays
async getUpcomingHolidays(req, res) {
const { limit = 5 } = req.query;
try {
const result = await pool.query(`
WITH upcoming AS (
SELECT
id,
name,
CASE
WHEN is_recurring THEN
CASE
WHEN MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int, recurring_month, recurring_day) >= CURRENT_DATE
THEN MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int, recurring_month, recurring_day)
ELSE MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int + 1, recurring_month, recurring_day)
END
ELSE date
END as next_date,
is_recurring
FROM holidays
)
SELECT * FROM upcoming
WHERE next_date >= CURRENT_DATE
ORDER BY next_date
LIMIT $1
`, [limit]);
res.json(result.rows);
} catch (error) {
logger.error('Error fetching upcoming holidays:', error);
res.status(500).json({ error: 'Failed to fetch upcoming holidays' });
}
}
}
module.exports = new HolidaysController();

View File

@@ -0,0 +1,282 @@
const pool = require('../config/database');
const redisClient = require('../config/redis');
const logger = require('../config/logger');
class RouteStopsController {
// Get all stops for a route (ordered by sequence)
async getRouteStops(req, res) {
const { routeId } = req.params;
try {
const result = await pool.query(`
SELECT
rs.id as route_stop_id,
rs.sequence,
rs.time_offset_minutes,
s.id as stop_id,
s.name,
s.address,
ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude
FROM route_stops rs
JOIN stops s ON rs.stop_id = s.id
WHERE rs.route_id = $1
ORDER BY rs.sequence
`, [routeId]);
res.json(result.rows);
} catch (error) {
logger.error('Error fetching route stops:', error);
res.status(500).json({ error: 'Failed to fetch route stops' });
}
}
// Add stop to route
async addStopToRoute(req, res) {
const { routeId } = req.params;
const { stopId, sequence, timeOffsetMinutes = 0 } = req.body;
if (!stopId) {
return res.status(400).json({
error: 'Missing required field: stopId'
});
}
try {
// Check if route exists
const routeCheck = await pool.query(
'SELECT id FROM routes WHERE id = $1',
[routeId]
);
if (routeCheck.rows.length === 0) {
return res.status(404).json({ error: 'Route not found' });
}
// Check if stop exists
const stopCheck = await pool.query(
'SELECT id FROM stops WHERE id = $1',
[stopId]
);
if (stopCheck.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' });
}
// If sequence not provided, add to the end
let actualSequence = sequence;
if (actualSequence === undefined || actualSequence === null) {
const maxSeq = await pool.query(
'SELECT COALESCE(MAX(sequence), 0) + 1 as next_seq FROM route_stops WHERE route_id = $1',
[routeId]
);
actualSequence = maxSeq.rows[0].next_seq;
} else {
// Shift existing stops if inserting in the middle
await pool.query(`
UPDATE route_stops
SET sequence = sequence + 1
WHERE route_id = $1 AND sequence >= $2
`, [routeId, actualSequence]);
}
// Insert the new route_stop
const result = await pool.query(`
INSERT INTO route_stops (route_id, stop_id, sequence, time_offset_minutes)
VALUES ($1, $2, $3, $4)
ON CONFLICT (route_id, stop_id, sequence) DO UPDATE
SET time_offset_minutes = $4
RETURNING *
`, [routeId, stopId, actualSequence, timeOffsetMinutes]);
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del('routes:all');
logger.info(`Stop ${stopId} added to route ${routeId} at position ${actualSequence}`);
res.status(201).json(result.rows[0]);
} catch (error) {
logger.error('Error adding stop to route:', error);
res.status(500).json({ error: 'Failed to add stop to route' });
}
}
// Update stop in route (change sequence or time offset)
async updateRouteStop(req, res) {
const { routeId, routeStopId } = req.params;
const { sequence, timeOffsetMinutes } = req.body;
try {
// Get current route_stop
const current = await pool.query(
'SELECT * FROM route_stops WHERE id = $1 AND route_id = $2',
[routeStopId, routeId]
);
if (current.rows.length === 0) {
return res.status(404).json({ error: 'Route stop not found' });
}
const currentStop = current.rows[0];
// If sequence is changing, reorder other stops
if (sequence !== undefined && sequence !== currentStop.sequence) {
if (sequence > currentStop.sequence) {
// Moving down: shift intermediate stops up
await pool.query(`
UPDATE route_stops
SET sequence = sequence - 1
WHERE route_id = $1
AND sequence > $2
AND sequence <= $3
`, [routeId, currentStop.sequence, sequence]);
} else {
// Moving up: shift intermediate stops down
await pool.query(`
UPDATE route_stops
SET sequence = sequence + 1
WHERE route_id = $1
AND sequence >= $2
AND sequence < $3
`, [routeId, sequence, currentStop.sequence]);
}
}
// Update the route_stop
const result = await pool.query(`
UPDATE route_stops
SET
sequence = COALESCE($1, sequence),
time_offset_minutes = COALESCE($2, time_offset_minutes)
WHERE id = $3 AND route_id = $4
RETURNING *
`, [sequence, timeOffsetMinutes, routeStopId, routeId]);
// Invalidate cache
await redisClient.del(`route:${routeId}`);
logger.info(`Route stop ${routeStopId} updated`);
res.json(result.rows[0]);
} catch (error) {
logger.error('Error updating route stop:', error);
res.status(500).json({ error: 'Failed to update route stop' });
}
}
// Remove stop from route
async removeStopFromRoute(req, res) {
const { routeId, routeStopId } = req.params;
try {
// Get the stop being removed to know its sequence
const current = await pool.query(
'SELECT sequence FROM route_stops WHERE id = $1 AND route_id = $2',
[routeStopId, routeId]
);
if (current.rows.length === 0) {
return res.status(404).json({ error: 'Route stop not found' });
}
const removedSequence = current.rows[0].sequence;
// Delete the route_stop
await pool.query(
'DELETE FROM route_stops WHERE id = $1 AND route_id = $2',
[routeStopId, routeId]
);
// Reorder remaining stops
await pool.query(`
UPDATE route_stops
SET sequence = sequence - 1
WHERE route_id = $1 AND sequence > $2
`, [routeId, removedSequence]);
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del('routes:all');
logger.info(`Stop removed from route ${routeId}`);
res.json({ message: 'Stop removed from route successfully' });
} catch (error) {
logger.error('Error removing stop from route:', error);
res.status(500).json({ error: 'Failed to remove stop from route' });
}
}
// Bulk update route stops (reorder all at once)
async reorderRouteStops(req, res) {
const { routeId } = req.params;
const { stops } = req.body; // Array of { stopId, sequence, timeOffsetMinutes }
if (!Array.isArray(stops) || stops.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'stops must be a non-empty array'
});
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// Delete all existing route_stops for this route
await client.query(
'DELETE FROM route_stops WHERE route_id = $1',
[routeId]
);
// Insert new order
for (const stop of stops) {
await client.query(`
INSERT INTO route_stops (route_id, stop_id, sequence, time_offset_minutes)
VALUES ($1, $2, $3, $4)
`, [routeId, stop.stopId, stop.sequence, stop.timeOffsetMinutes || 0]);
}
await client.query('COMMIT');
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del('routes:all');
logger.info(`Route ${routeId} stops reordered: ${stops.length} stops`);
res.json({ message: 'Route stops reordered successfully', count: stops.length });
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error reordering route stops:', error);
res.status(500).json({ error: 'Failed to reorder route stops' });
} finally {
client.release();
}
}
// Get available stops (not yet in this route)
async getAvailableStops(req, res) {
const { routeId } = req.params;
try {
const result = await pool.query(`
SELECT
s.id,
s.name,
s.address,
ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude
FROM stops s
WHERE s.is_active = true
AND s.id NOT IN (
SELECT stop_id FROM route_stops WHERE route_id = $1
)
ORDER BY s.name
`, [routeId]);
res.json(result.rows);
} catch (error) {
logger.error('Error fetching available stops:', error);
res.status(500).json({ error: 'Failed to fetch available stops' });
}
}
}
module.exports = new RouteStopsController();

View File

@@ -0,0 +1,181 @@
const pool = require('../config/database');
const redisClient = require('../config/redis');
const logger = require('../config/logger');
const CACHE_TTL = 3600; // 1 hour
class RoutesController {
// Get all routes
async getAllRoutes(req, res) {
try {
// Try cache first
const cached = await redisClient.get('routes:all');
if (cached) {
return res.json(JSON.parse(cached));
}
const result = await pool.query(`
SELECT
r.*,
COUNT(DISTINCT rs.stop_id) as stops_count
FROM routes r
LEFT JOIN route_stops rs ON r.id = rs.route_id
WHERE r.is_active = true
GROUP BY r.id
ORDER BY r.route_number
`);
const routes = result.rows;
// Cache the result
await redisClient.setEx('routes:all', CACHE_TTL, JSON.stringify(routes));
res.json(routes);
} catch (error) {
logger.error('Error fetching routes:', error);
res.status(500).json({ error: 'Failed to fetch routes' });
}
}
// Get route by ID with stops
async getRouteById(req, res) {
const { id } = req.params;
try {
const cacheKey = `route:${id}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
// Get route info
const routeResult = await pool.query(
'SELECT * FROM routes WHERE id = $1 AND is_active = true',
[id]
);
if (routeResult.rows.length === 0) {
return res.status(404).json({ error: 'Route not found' });
}
// Get stops for this route
const stopsResult = await pool.query(`
SELECT
s.*,
ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude,
rs.sequence,
rs.time_offset_minutes
FROM stops s
JOIN route_stops rs ON s.id = rs.stop_id
WHERE rs.route_id = $1
ORDER BY rs.sequence
`, [id]);
const route = {
...routeResult.rows[0],
stops: stopsResult.rows
};
// Cache the result
await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(route));
res.json(route);
} catch (error) {
logger.error('Error fetching route:', error);
res.status(500).json({ error: 'Failed to fetch route' });
}
}
// Create new route
async createRoute(req, res) {
const { route_number, name, type, color, description } = req.body;
if (!route_number || !name || !type) {
return res.status(400).json({
error: 'Missing required fields',
required: ['route_number', 'name', 'type']
});
}
try {
const result = await pool.query(`
INSERT INTO routes (route_number, name, type, color, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [route_number, name, type, color || '#0066CC', description]);
// Invalidate cache
await redisClient.del('routes:all');
logger.info(`Route created: ${route_number} - ${name}`);
res.status(201).json(result.rows[0]);
} catch (error) {
logger.error('Error creating route:', error);
res.status(500).json({ error: 'Failed to create route' });
}
}
// Update route
async updateRoute(req, res) {
const { id } = req.params;
const { route_number, name, type, color, description, is_active } = req.body;
try {
const result = await pool.query(`
UPDATE routes
SET
route_number = COALESCE($1, route_number),
name = COALESCE($2, name),
type = COALESCE($3, type),
color = COALESCE($4, color),
description = COALESCE($5, description),
is_active = COALESCE($6, is_active)
WHERE id = $7
RETURNING *
`, [route_number, name, type, color, description, is_active, id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Route not found' });
}
// Invalidate cache
await redisClient.del('routes:all');
await redisClient.del(`route:${id}`);
logger.info(`Route updated: ${id}`);
res.json(result.rows[0]);
} catch (error) {
logger.error('Error updating route:', error);
res.status(500).json({ error: 'Failed to update route' });
}
}
// Delete route
async deleteRoute(req, res) {
const { id } = req.params;
try {
const result = await pool.query(
'DELETE FROM routes WHERE id = $1 RETURNING *',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Route not found' });
}
// Invalidate cache
await redisClient.del('routes:all');
await redisClient.del(`route:${id}`);
logger.info(`Route deleted: ${id}`);
res.json({ message: 'Route deleted successfully' });
} catch (error) {
logger.error('Error deleting route:', error);
res.status(500).json({ error: 'Failed to delete route' });
}
}
}
module.exports = new RoutesController();

View File

@@ -0,0 +1,348 @@
const pool = require('../config/database');
const redisClient = require('../config/redis');
const logger = require('../config/logger');
class SchedulesController {
// Get all schedules for a route
async getRouteSchedules(req, res) {
const { routeId } = req.params;
try {
const result = await pool.query(`
SELECT
s.*,
r.route_number,
r.name as route_name
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE s.route_id = $1
ORDER BY
CASE s.day_type
WHEN 'weekday' THEN 1
WHEN 'saturday' THEN 2
WHEN 'sunday' THEN 3
WHEN 'holiday' THEN 4
END
`, [routeId]);
res.json(result.rows);
} catch (error) {
logger.error('Error fetching route schedules:', error);
res.status(500).json({ error: 'Failed to fetch schedules' });
}
}
// Get schedule by ID
async getScheduleById(req, res) {
const { id } = req.params;
try {
const result = await pool.query(`
SELECT
s.*,
r.route_number,
r.name as route_name
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE s.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Schedule not found' });
}
res.json(result.rows[0]);
} catch (error) {
logger.error('Error fetching schedule:', error);
res.status(500).json({ error: 'Failed to fetch schedule' });
}
}
// Create schedule
async createSchedule(req, res) {
const { routeId, dayType, departureTimes, validFrom, validUntil } = req.body;
if (!routeId || !dayType || !departureTimes) {
return res.status(400).json({
error: 'Missing required fields',
required: ['routeId', 'dayType', 'departureTimes']
});
}
// Validate day type
const validDayTypes = ['weekday', 'saturday', 'sunday', 'holiday'];
if (!validDayTypes.includes(dayType)) {
return res.status(400).json({
error: 'Invalid day type',
valid: validDayTypes
});
}
// Validate and sort departure times
const sortedTimes = this.validateAndSortTimes(departureTimes);
if (!sortedTimes) {
return res.status(400).json({
error: 'Invalid departure times format',
expected: 'Array of times in HH:MM or HH:MM:SS format'
});
}
try {
// Check if schedule already exists for this route and day type
const existing = await pool.query(`
SELECT id FROM schedules
WHERE route_id = $1 AND day_type = $2
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
`, [routeId, dayType]);
if (existing.rows.length > 0) {
return res.status(409).json({
error: 'Schedule already exists',
message: `A schedule for ${dayType} already exists for this route`,
existingId: existing.rows[0].id
});
}
const result = await pool.query(`
INSERT INTO schedules (route_id, day_type, departure_times, valid_from, valid_until)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [routeId, dayType, sortedTimes, validFrom || null, validUntil || null]);
// Invalidate cache
await redisClient.del(`route:${routeId}`);
logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`);
res.status(201).json(result.rows[0]);
} catch (error) {
logger.error('Error creating schedule:', error);
res.status(500).json({ error: 'Failed to create schedule' });
}
}
// Update schedule
async updateSchedule(req, res) {
const { id } = req.params;
const { departureTimes, validFrom, validUntil } = req.body;
try {
// Get current schedule to find route_id
const current = await pool.query('SELECT route_id FROM schedules WHERE id = $1', [id]);
if (current.rows.length === 0) {
return res.status(404).json({ error: 'Schedule not found' });
}
let sortedTimes = null;
if (departureTimes) {
sortedTimes = this.validateAndSortTimes(departureTimes);
if (!sortedTimes) {
return res.status(400).json({
error: 'Invalid departure times format'
});
}
}
const result = await pool.query(`
UPDATE schedules
SET
departure_times = COALESCE($1, departure_times),
valid_from = COALESCE($2, valid_from),
valid_until = COALESCE($3, valid_until),
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
RETURNING *
`, [sortedTimes, validFrom, validUntil, id]);
// Invalidate cache
await redisClient.del(`route:${current.rows[0].route_id}`);
logger.info(`Schedule ${id} updated`);
res.json(result.rows[0]);
} catch (error) {
logger.error('Error updating schedule:', error);
res.status(500).json({ error: 'Failed to update schedule' });
}
}
// Delete schedule
async deleteSchedule(req, res) {
const { id } = req.params;
try {
const result = await pool.query(
'DELETE FROM schedules WHERE id = $1 RETURNING route_id',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Schedule not found' });
}
// Invalidate cache
await redisClient.del(`route:${result.rows[0].route_id}`);
logger.info(`Schedule ${id} deleted`);
res.json({ message: 'Schedule deleted successfully' });
} catch (error) {
logger.error('Error deleting schedule:', error);
res.status(500).json({ error: 'Failed to delete schedule' });
}
}
// Get schedule for today (or specific date) for a route
async getTodaySchedule(req, res) {
const { routeId } = req.params;
const { date } = req.query; // Optional: specific date in YYYY-MM-DD format
try {
const targetDate = date || new Date().toISOString().split('T')[0];
// Get schedule type for the date (considers holidays)
const typeResult = await pool.query(
'SELECT get_schedule_type($1::date) as schedule_type',
[targetDate]
);
let scheduleType = typeResult.rows[0].schedule_type;
// If holiday, use sunday schedule (as per Belarus rules)
// But first check if there's a specific holiday schedule
let result = await pool.query(`
SELECT s.*, r.route_number, r.name as route_name
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE s.route_id = $1
AND s.day_type = $2
AND (s.valid_from IS NULL OR s.valid_from <= $3::date)
AND (s.valid_until IS NULL OR s.valid_until >= $3::date)
LIMIT 1
`, [routeId, scheduleType, targetDate]);
// If holiday and no specific holiday schedule, fall back to sunday
if (result.rows.length === 0 && scheduleType === 'holiday') {
result = await pool.query(`
SELECT s.*, r.route_number, r.name as route_name
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE s.route_id = $1
AND s.day_type = 'sunday'
AND (s.valid_from IS NULL OR s.valid_from <= $2::date)
AND (s.valid_until IS NULL OR s.valid_until >= $2::date)
LIMIT 1
`, [routeId, targetDate]);
}
if (result.rows.length === 0) {
return res.status(404).json({
error: 'No schedule found',
date: targetDate,
scheduleType: scheduleType
});
}
// Check if today is a holiday
const holidayCheck = await pool.query(
'SELECT name FROM holidays WHERE date = $1',
[targetDate]
);
res.json({
date: targetDate,
scheduleType: scheduleType,
isHoliday: holidayCheck.rows.length > 0,
holidayName: holidayCheck.rows[0]?.name || null,
schedule: result.rows[0]
});
} catch (error) {
logger.error('Error fetching today schedule:', error);
res.status(500).json({ error: 'Failed to fetch schedule' });
}
}
// Copy schedule from one day type to another
async copySchedule(req, res) {
const { routeId } = req.params;
const { fromDayType, toDayType } = req.body;
if (!fromDayType || !toDayType) {
return res.status(400).json({
error: 'Missing required fields',
required: ['fromDayType', 'toDayType']
});
}
try {
// Get source schedule
const source = await pool.query(`
SELECT departure_times FROM schedules
WHERE route_id = $1 AND day_type = $2
ORDER BY created_at DESC
LIMIT 1
`, [routeId, fromDayType]);
if (source.rows.length === 0) {
return res.status(404).json({
error: 'Source schedule not found'
});
}
// Check if target already exists
const existing = await pool.query(`
SELECT id FROM schedules
WHERE route_id = $1 AND day_type = $2
`, [routeId, toDayType]);
if (existing.rows.length > 0) {
// Update existing
const result = await pool.query(`
UPDATE schedules
SET departure_times = $1, updated_at = CURRENT_TIMESTAMP
WHERE route_id = $2 AND day_type = $3
RETURNING *
`, [source.rows[0].departure_times, routeId, toDayType]);
res.json(result.rows[0]);
} else {
// Create new
const result = await pool.query(`
INSERT INTO schedules (route_id, day_type, departure_times)
VALUES ($1, $2, $3)
RETURNING *
`, [routeId, toDayType, source.rows[0].departure_times]);
res.status(201).json(result.rows[0]);
}
// Invalidate cache
await redisClient.del(`route:${routeId}`);
logger.info(`Schedule copied from ${fromDayType} to ${toDayType} for route ${routeId}`);
} catch (error) {
logger.error('Error copying schedule:', error);
res.status(500).json({ error: 'Failed to copy schedule' });
}
}
// Helper: Validate and sort times
validateAndSortTimes(times) {
if (!Array.isArray(times)) return null;
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(:[0-5][0-9])?$/;
const validTimes = [];
for (const time of times) {
if (!timeRegex.test(time)) return null;
// Normalize to HH:MM:SS format
const parts = time.split(':');
const normalized = `${parts[0].padStart(2, '0')}:${parts[1]}:${parts[2] || '00'}`;
validTimes.push(normalized);
}
// Sort times
validTimes.sort();
return validTimes;
}
}
module.exports = new SchedulesController();

View File

@@ -0,0 +1,237 @@
const pool = require('../config/database');
const redisClient = require('../config/redis');
const logger = require('../config/logger');
const CACHE_TTL = 3600;
class StopsController {
// Get all stops
async getAllStops(req, res) {
try {
const cached = await redisClient.get('stops:all');
if (cached) {
return res.json(JSON.parse(cached));
}
const result = await pool.query(`
SELECT
s.*,
ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude,
COUNT(DISTINCT rs.route_id) as routes_count
FROM stops s
LEFT JOIN route_stops rs ON s.id = rs.stop_id
WHERE s.is_active = true
GROUP BY s.id
ORDER BY s.name
`);
const stops = result.rows;
await redisClient.setEx('stops:all', CACHE_TTL, JSON.stringify(stops));
res.json(stops);
} catch (error) {
logger.error('Error fetching stops:', error);
res.status(500).json({ error: 'Failed to fetch stops' });
}
}
// Get stop by ID
async getStopById(req, res) {
const { id } = req.params;
try {
const result = await pool.query(`
SELECT
s.*,
ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude
FROM stops s
WHERE s.id = $1 AND s.is_active = true
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' });
}
// Get routes passing through this stop
const routesResult = await pool.query(`
SELECT
r.*,
rs.sequence,
rs.time_offset_minutes
FROM routes r
JOIN route_stops rs ON r.id = rs.route_id
WHERE rs.stop_id = $1 AND r.is_active = true
ORDER BY r.route_number
`, [id]);
const stop = {
...result.rows[0],
routes: routesResult.rows
};
res.json(stop);
} catch (error) {
logger.error('Error fetching stop:', error);
res.status(500).json({ error: 'Failed to fetch stop' });
}
}
// Get nearby stops (PostGIS query)
async getNearbyStops(req, res) {
const { lat, lon, radius = 500 } = req.query;
if (!lat || !lon) {
return res.status(400).json({
error: 'Missing required parameters',
required: ['lat', 'lon']
});
}
try {
const result = await pool.query(`
SELECT
s.*,
ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude,
ST_Distance(
s.location,
ST_MakePoint($2, $1)::geography
) as distance_meters
FROM stops s
WHERE s.is_active = true
AND ST_DWithin(
s.location,
ST_MakePoint($2, $1)::geography,
$3
)
ORDER BY distance_meters
LIMIT 20
`, [parseFloat(lat), parseFloat(lon), parseFloat(radius)]);
res.json(result.rows);
} catch (error) {
logger.error('Error fetching nearby stops:', error);
res.status(500).json({ error: 'Failed to fetch nearby stops' });
}
}
// Create stop
async createStop(req, res) {
const { name, latitude, longitude, address, description } = req.body;
if (!name || !latitude || !longitude) {
return res.status(400).json({
error: 'Missing required fields',
required: ['name', 'latitude', 'longitude']
});
}
try {
const result = await pool.query(`
INSERT INTO stops (name, location, address, description)
VALUES (
$1,
ST_SetSRID(ST_MakePoint($3, $2), 4326)::geography,
$4,
$5
)
RETURNING
*,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude
`, [name, latitude, longitude, address, description]);
await redisClient.del('stops:all');
logger.info(`Stop created: ${name}`);
res.status(201).json(result.rows[0]);
} catch (error) {
logger.error('Error creating stop:', error);
res.status(500).json({ error: 'Failed to create stop' });
}
}
// Update stop
async updateStop(req, res) {
const { id } = req.params;
const { name, latitude, longitude, address, description, is_active } = req.body;
try {
let query = 'UPDATE stops SET ';
const values = [];
let paramCount = 1;
if (name !== undefined) {
query += `name = $${paramCount}, `;
values.push(name);
paramCount++;
}
if (latitude !== undefined && longitude !== undefined) {
query += `location = ST_SetSRID(ST_MakePoint($${paramCount + 1}, $${paramCount}), 4326)::geography, `;
values.push(latitude, longitude);
paramCount += 2;
}
if (address !== undefined) {
query += `address = $${paramCount}, `;
values.push(address);
paramCount++;
}
if (description !== undefined) {
query += `description = $${paramCount}, `;
values.push(description);
paramCount++;
}
if (is_active !== undefined) {
query += `is_active = $${paramCount}, `;
values.push(is_active);
paramCount++;
}
query = query.slice(0, -2); // Remove trailing comma
query += ` WHERE id = $${paramCount} RETURNING *, ST_Y(location::geometry) as latitude, ST_X(location::geometry) as longitude`;
values.push(id);
const result = await pool.query(query, values);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' });
}
await redisClient.del('stops:all');
logger.info(`Stop updated: ${id}`);
res.json(result.rows[0]);
} catch (error) {
logger.error('Error updating stop:', error);
res.status(500).json({ error: 'Failed to update stop' });
}
}
// Delete stop
async deleteStop(req, res) {
const { id } = req.params;
try {
const result = await pool.query(
'DELETE FROM stops WHERE id = $1 RETURNING *',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' });
}
await redisClient.del('stops:all');
logger.info(`Stop deleted: ${id}`);
res.json({ message: 'Stop deleted successfully' });
} catch (error) {
logger.error('Error deleting stop:', error);
res.status(500).json({ error: 'Failed to delete stop' });
}
}
}
module.exports = new StopsController();

View File

@@ -0,0 +1,250 @@
const pool = require('../config/database');
const logger = require('../config/logger');
class SyncController {
// Get all data or incremental updates
async sync(req, res) {
const { lastSync } = req.query;
try {
if (!lastSync) {
// Full sync - first time
return await this.fullSync(req, res);
} else {
// Incremental sync
return await this.incrementalSync(req, res, lastSync);
}
} catch (error) {
logger.error('Error during sync:', error);
res.status(500).json({ error: 'Sync failed' });
}
}
// Full synchronization (all data)
async fullSync(req, res) {
try {
// Get all active routes
const routesResult = await pool.query(`
SELECT * FROM routes WHERE is_active = true
`);
// Get all active stops with coordinates
const stopsResult = await pool.query(`
SELECT
*,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude
FROM stops
WHERE is_active = true
`);
// Get route-stop relationships
const routeStopsResult = await pool.query(`
SELECT rs.*
FROM route_stops rs
JOIN routes r ON rs.route_id = r.id
JOIN stops s ON rs.stop_id = s.id
WHERE r.is_active = true AND s.is_active = true
`);
// Get schedules
const schedulesResult = await pool.query(`
SELECT s.*
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE r.is_active = true
AND (s.valid_from IS NULL OR s.valid_from <= CURRENT_DATE)
AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE)
`);
// Get active alerts
const alertsResult = await pool.query(`
SELECT * FROM alerts
WHERE is_active = true
AND start_time <= CURRENT_TIMESTAMP
AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP)
`);
// Get holidays for current and next year
const holidaysResult = await pool.query(`
SELECT
id,
CASE
WHEN is_recurring THEN date
ELSE date
END as date,
name,
is_recurring,
recurring_month,
recurring_day
FROM holidays
WHERE EXTRACT(YEAR FROM date) >= EXTRACT(YEAR FROM CURRENT_DATE)
OR is_recurring = true
ORDER BY date
`);
const syncData = {
syncType: 'full',
timestamp: new Date().toISOString(),
version: '1.0',
data: {
routes: routesResult.rows,
stops: stopsResult.rows,
routeStops: routeStopsResult.rows,
schedules: schedulesResult.rows,
alerts: alertsResult.rows,
holidays: holidaysResult.rows
},
metadata: {
routesCount: routesResult.rows.length,
stopsCount: stopsResult.rows.length,
schedulesCount: schedulesResult.rows.length,
holidaysCount: holidaysResult.rows.length
}
};
logger.info('Full sync completed');
res.json(syncData);
} catch (error) {
logger.error('Full sync error:', error);
throw error;
}
}
// Incremental synchronization (changes since lastSync)
async incrementalSync(req, res, lastSync) {
try {
const lastSyncDate = new Date(lastSync);
// Get changes from sync_log
const changesResult = await pool.query(`
SELECT DISTINCT ON (table_name, record_id)
table_name,
record_id,
action,
timestamp
FROM sync_log
WHERE timestamp > $1
ORDER BY table_name, record_id, timestamp DESC
`, [lastSyncDate]);
const changes = {
routes: { updated: [], deleted: [] },
stops: { updated: [], deleted: [] },
routeStops: { updated: [], deleted: [] },
schedules: { updated: [], deleted: [] }
};
// Process changes
for (const change of changesResult.rows) {
const { table_name, record_id, action } = change;
if (action === 'delete') {
if (changes[table_name]) {
changes[table_name].deleted.push(record_id);
}
} else {
// Get updated record
const record = await this.getRecord(table_name, record_id);
if (record && changes[table_name]) {
changes[table_name].updated.push(record);
}
}
}
// Get new alerts
const alertsResult = await pool.query(`
SELECT * FROM alerts
WHERE is_active = true
AND created_at > $1
`, [lastSyncDate]);
const syncData = {
syncType: 'incremental',
timestamp: new Date().toISOString(),
lastSync: lastSync,
changes: changes,
alerts: alertsResult.rows,
metadata: {
changesCount: changesResult.rows.length
}
};
logger.info(`Incremental sync completed: ${changesResult.rows.length} changes`);
res.json(syncData);
} catch (error) {
logger.error('Incremental sync error:', error);
throw error;
}
}
// Helper: Get record from table
async getRecord(tableName, recordId) {
try {
let query = '';
switch (tableName) {
case 'routes':
query = 'SELECT * FROM routes WHERE id = $1 AND is_active = true';
break;
case 'stops':
query = `
SELECT
*,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude
FROM stops
WHERE id = $1 AND is_active = true
`;
break;
case 'route_stops':
query = 'SELECT * FROM route_stops WHERE id = $1';
break;
case 'schedules':
query = `
SELECT * FROM schedules
WHERE id = $1
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
`;
break;
default:
return null;
}
const result = await pool.query(query, [recordId]);
return result.rows.length > 0 ? result.rows[0] : null;
} catch (error) {
logger.error(`Error getting record from ${tableName}:`, error);
return null;
}
}
// Get sync status
async getSyncStatus(req, res) {
try {
const stats = await pool.query(`
SELECT
(SELECT COUNT(*) FROM routes WHERE is_active = true) as routes_count,
(SELECT COUNT(*) FROM stops WHERE is_active = true) as stops_count,
(SELECT COUNT(*) FROM schedules) as schedules_count,
(SELECT MAX(timestamp) FROM sync_log) as last_change
`);
res.json({
status: 'ready',
timestamp: new Date().toISOString(),
database: stats.rows[0]
});
} catch (error) {
logger.error('Error getting sync status:', error);
res.status(500).json({ error: 'Failed to get sync status' });
}
}
}
module.exports = new SyncController();

View File

@@ -0,0 +1,84 @@
const logger = require('../config/logger');
const telemetryService = require('../services/telemetryService');
const { broadcastToRoute } = require('../services/realtimeService');
function sendError(res, error, fallbackMessage) {
if (error.status) {
return res.status(error.status).json({ error: error.message });
}
logger.error(fallbackMessage, error);
return res.status(500).json({ error: fallbackMessage });
}
class TelemetryController {
async ingest(req, res) {
try {
const sourceType = req.body.sourceType || (req.user ? 'user' : 'api-key');
const sourceRef = req.body.sourceRef || (
req.user
? `user:${req.user.id}`
: req.apiKeyInfo
? `api-key:${req.apiKeyInfo.id}`
: null
);
const vehicle = await telemetryService.ingestTelemetry({
...req.body,
sourceType,
sourceRef,
rawPayload: req.body
});
broadcastToRoute(vehicle.route_id, {
type: 'vehicle:position',
routeId: vehicle.route_id,
vehicle
});
return res.status(201).json({
message: 'Telemetry ingested successfully',
vehicle
});
} catch (error) {
return sendError(res, error, 'Failed to ingest telemetry');
}
}
async getRouteLiveVehicles(req, res) {
try {
const includeStale = req.query.includeStale === 'true';
const staleAfterSeconds = req.query.staleAfterSeconds
? Number.parseInt(req.query.staleAfterSeconds, 10)
: undefined;
const vehicles = await telemetryService.getRouteLiveVehicles(req.params.routeId, {
includeStale,
staleAfterSeconds
});
return res.json(vehicles);
} catch (error) {
return sendError(res, error, 'Failed to fetch route live vehicles');
}
}
async getVehicleLiveState(req, res) {
try {
const staleAfterSeconds = req.query.staleAfterSeconds
? Number.parseInt(req.query.staleAfterSeconds, 10)
: undefined;
const vehicle = await telemetryService.getVehicleLiveState(
req.params.vehicleId,
staleAfterSeconds
);
return res.json(vehicle);
} catch (error) {
return sendError(res, error, 'Failed to fetch vehicle live state');
}
}
}
module.exports = new TelemetryController();

View File

@@ -0,0 +1,143 @@
const pool = require('../config/database');
const logger = require('../config/logger');
const fs = require('fs').promises;
const path = require('path');
class UploadController {
async uploadAvatar(req, res) {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const userId = req.user.id;
const file = req.file;
// Generate URL for the avatar
const avatarUrl = `/uploads/avatars/${file.filename}`;
// Get old avatar to delete it
const oldUserResult = await pool.query(
'SELECT avatar_url FROM users WHERE id = $1',
[userId]
);
// Update user's avatar_url in database
const result = await pool.query(
'UPDATE users SET avatar_url = $1 WHERE id = $2 RETURNING id, username, avatar_url',
[avatarUrl, userId]
);
// Delete old avatar if exists
if (oldUserResult.rows[0]?.avatar_url) {
const oldAvatarPath = path.join(__dirname, '../../public', oldUserResult.rows[0].avatar_url);
try {
await fs.unlink(oldAvatarPath);
} catch (error) {
logger.warn('Failed to delete old avatar:', error.message);
}
}
logger.info(`Avatar uploaded for user: ${result.rows[0].username}`);
res.json({
message: 'Avatar uploaded successfully',
avatar_url: avatarUrl,
user: result.rows[0]
});
} catch (error) {
logger.error('Upload avatar error:', error);
// Clean up uploaded file on error
if (req.file) {
try {
await fs.unlink(req.file.path);
} catch (unlinkError) {
logger.error('Failed to clean up file:', unlinkError);
}
}
res.status(500).json({ error: 'Internal server error' });
}
}
async deleteAvatar(req, res) {
try {
const userId = req.user.id;
// Get current avatar
const userResult = await pool.query(
'SELECT avatar_url FROM users WHERE id = $1',
[userId]
);
if (!userResult.rows[0]?.avatar_url) {
return res.status(404).json({ error: 'No avatar to delete' });
}
const avatarPath = path.join(__dirname, '../../public', userResult.rows[0].avatar_url);
// Delete from database
await pool.query(
'UPDATE users SET avatar_url = NULL WHERE id = $1',
[userId]
);
// Delete file
try {
await fs.unlink(avatarPath);
} catch (error) {
logger.warn('Failed to delete avatar file:', error.message);
}
logger.info(`Avatar deleted for user ID: ${userId}`);
res.json({ message: 'Avatar deleted successfully' });
} catch (error) {
logger.error('Delete avatar error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Admin endpoint to delete any user's avatar
async deleteUserAvatar(req, res) {
try {
const { userId } = req.params;
const userResult = await pool.query(
'SELECT avatar_url FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
if (!userResult.rows[0]?.avatar_url) {
return res.status(404).json({ error: 'User has no avatar' });
}
const avatarPath = path.join(__dirname, '../../public', userResult.rows[0].avatar_url);
await pool.query(
'UPDATE users SET avatar_url = NULL WHERE id = $1',
[userId]
);
try {
await fs.unlink(avatarPath);
} catch (error) {
logger.warn('Failed to delete avatar file:', error.message);
}
logger.info(`Avatar deleted for user ID: ${userId} by admin: ${req.user.username}`);
res.json({ message: 'Avatar deleted successfully' });
} catch (error) {
logger.error('Delete user avatar error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
}
module.exports = new UploadController();

View File

@@ -0,0 +1,182 @@
const bcrypt = require('bcrypt');
const pool = require('../config/database');
const logger = require('../config/logger');
class UsersController {
// Get all users (admin only)
async getAllUsers(req, res) {
try {
const result = await pool.query(`
SELECT id, username, full_name, email, role, is_active, last_login, created_at
FROM users
ORDER BY created_at DESC
`);
res.json(result.rows);
} catch (error) {
logger.error('Get users error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Get user by ID
async getUserById(req, res) {
try {
const { id } = req.params;
const result = await pool.query(`
SELECT id, username, full_name, email, role, is_active, last_login, created_at
FROM users
WHERE id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(result.rows[0]);
} catch (error) {
logger.error('Get user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Create user (admin only)
async createUser(req, res) {
try {
const { username, password, full_name, email, role = 'user' } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
// Check if username exists
const existingUser = await pool.query(
'SELECT id FROM users WHERE username = $1',
[username]
);
if (existingUser.rows.length > 0) {
return res.status(400).json({ error: 'Username already exists' });
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user
const result = await pool.query(`
INSERT INTO users (username, password_hash, full_name, email, role, is_active)
VALUES ($1, $2, $3, $4, $5, true)
RETURNING id, username, full_name, email, role, is_active, created_at
`, [username, passwordHash, full_name || null, email || null, role]);
logger.info(`User created: ${username} by ${req.user.username}`);
res.status(201).json(result.rows[0]);
} catch (error) {
logger.error('Create user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Update user (admin only)
async updateUser(req, res) {
try {
const { id } = req.params;
const { full_name, email, role, is_active } = req.body;
// Don't allow deactivating yourself
if (id == req.user.id && is_active === false) {
return res.status(400).json({ error: 'Cannot deactivate yourself' });
}
const result = await pool.query(`
UPDATE users
SET full_name = COALESCE($1, full_name),
email = COALESCE($2, email),
role = COALESCE($3, role),
is_active = COALESCE($4, is_active)
WHERE id = $5
RETURNING id, username, full_name, email, role, is_active
`, [full_name, email, role, is_active, id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
logger.info(`User updated: ${result.rows[0].username} by ${req.user.username}`);
res.json(result.rows[0]);
} catch (error) {
logger.error('Update user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Delete user (admin only)
async deleteUser(req, res) {
try {
const { id } = req.params;
// Don't allow deleting yourself
if (id == req.user.id) {
return res.status(400).json({ error: 'Cannot delete yourself' });
}
const result = await pool.query(
'DELETE FROM users WHERE id = $1 RETURNING username',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
logger.info(`User deleted: ${result.rows[0].username} by ${req.user.username}`);
res.json({ message: 'User deleted successfully' });
} catch (error) {
logger.error('Delete user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Reset user password (admin only)
async resetPassword(req, res) {
try {
const { id } = req.params;
const { newPassword } = req.body;
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
const passwordHash = await bcrypt.hash(newPassword, 10);
const result = await pool.query(
'UPDATE users SET password_hash = $1 WHERE id = $2 RETURNING username',
[passwordHash, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
// Invalidate all user sessions
await pool.query('DELETE FROM user_sessions WHERE user_id = $1', [id]);
logger.info(`Password reset for user: ${result.rows[0].username} by ${req.user.username}`);
res.json({ message: 'Password reset successfully' });
} catch (error) {
logger.error('Reset password error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
}
module.exports = new UsersController();

155
backend/src/index.js Normal file
View File

@@ -0,0 +1,155 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
require('dotenv').config();
const logger = require('./config/logger');
const apiRoutes = require('./routes/api');
const { authenticateApiKey } = require('./middleware/auth');
const createRateLimiter = require('./middleware/rateLimiter');
const { ensureRealtimeSchema } = require('./services/realtimeSchema');
const { attachRealtimeServer, closeRealtimeServer } = require('./services/realtimeService');
const app = express();
const PORT = process.env.PORT || 3000;
let server;
// Security middleware
app.use(helmet({
crossOriginResourcePolicy: false,
}));
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true
}));
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Compression
app.use(compression());
// Static files for avatars
const path = require('path');
app.use('/uploads', express.static(path.join(__dirname, '../public/uploads')));
// Logging middleware
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`, {
ip: req.ip,
userAgent: req.get('user-agent')
});
next();
});
// Health check (no auth required)
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '1.0.0'
});
});
// Auth routes (no API key required)
const authRouter = require('./routes/auth');
app.use('/api/v1/auth',
createRateLimiter(100, 3600000), // 100 requests per hour for auth
authRouter
);
// Upload routes (JWT auth required, no API key) - TEMPORARILY DISABLED
// Uncomment after installing multer: npm install multer
try {
const uploadRouter = require('./routes/upload');
app.use('/api/v1/upload',
createRateLimiter(50, 3600000), // 50 uploads per hour
uploadRouter
);
logger.info('Upload routes enabled');
} catch (error) {
logger.warn('Upload routes disabled - multer not installed. Run: npm install multer');
}
// API routes with API key authentication and rate limiting
app.use('/api/v1',
authenticateApiKey,
createRateLimiter(1000, 3600000), // 1000 requests per hour
apiRoutes
);
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Route ${req.method} ${req.path} not found`
});
});
// Global error handler
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
// Multer errors
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: 'File too large',
message: 'Maximum file size is 5MB'
});
}
if (err.message && err.message.includes('Only image files are allowed')) {
return res.status(400).json({
error: 'Invalid file type',
message: err.message
});
}
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
});
});
// Graceful shutdown
function shutdown(signal) {
logger.info('SIGTERM signal received: closing HTTP server');
logger.info(`${signal} signal received: closing HTTP server`);
closeRealtimeServer();
if (!server) {
process.exit(0);
}
server.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
async function startServer() {
try {
await ensureRealtimeSchema();
server = app.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
logger.info(`API Base: http://localhost:${PORT}/api/v1`);
});
attachRealtimeServer(server);
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
}
}
startServer();
module.exports = app;

View File

@@ -0,0 +1,115 @@
const pool = require('../config/database');
const logger = require('../config/logger');
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
function verifyJwtToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
async function getApiKeyInfo(apiKey, touchLastUsed = false) {
if (!apiKey) {
return null;
}
const query = touchLastUsed
? `UPDATE api_keys
SET last_used_at = CURRENT_TIMESTAMP
WHERE key = $1 AND is_active = true
RETURNING id, app_name, rate_limit`
: `SELECT id, app_name, rate_limit
FROM api_keys
WHERE key = $1 AND is_active = true`;
const result = await pool.query(query, [apiKey]);
return result.rows[0] || null;
}
// Verify JWT token
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
const user = verifyJwtToken(token);
if (!user) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
}
// Check if user is admin
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
async function authenticateApiKey(req, res, next) {
// Check for JWT token first
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (token) {
// If JWT token is present, verify it
try {
const user = verifyJwtToken(token);
if (!user) {
throw new Error('Invalid JWT token');
}
req.user = user;
return next();
} catch (error) {
// JWT token is invalid, continue to API key check
logger.warn('Invalid JWT token in request');
}
}
// Fall back to API key authentication
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({
error: 'Authentication required',
message: 'Please provide either Authorization header with JWT token or X-API-Key header'
});
}
try {
const apiKeyInfo = await getApiKeyInfo(apiKey, true);
if (!apiKeyInfo) {
logger.warn(`Invalid API key attempt: ${apiKey.substring(0, 8)}...`);
return res.status(403).json({
error: 'Invalid API key',
message: 'The provided API key is invalid or inactive'
});
}
req.apiKeyInfo = apiKeyInfo;
next();
} catch (error) {
logger.error('Authentication error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
authenticateToken,
requireAdmin,
authenticateApiKey,
verifyJwtToken,
getApiKeyInfo
};

View File

@@ -0,0 +1,66 @@
const rateLimit = require('express-rate-limit');
const redisClient = require('../config/redis');
// In-memory fallback store if Redis is unavailable
const memoryStore = new Map();
async function getRateLimitStore() {
// Custom Redis store
return {
async increment(key) {
try {
const current = await redisClient.get(key);
const count = current ? parseInt(current) + 1 : 1;
await redisClient.setEx(key, 3600, count.toString()); // 1 hour TTL
return { totalHits: count, resetTime: null };
} catch (error) {
// Fallback to memory store
const count = (memoryStore.get(key) || 0) + 1;
memoryStore.set(key, count);
setTimeout(() => memoryStore.delete(key), 3600000); // 1 hour
return { totalHits: count, resetTime: null };
}
},
async decrement(key) {
try {
await redisClient.decr(key);
} catch (error) {
const count = memoryStore.get(key) || 0;
memoryStore.set(key, Math.max(0, count - 1));
}
},
async resetKey(key) {
try {
await redisClient.del(key);
} catch (error) {
memoryStore.delete(key);
}
}
};
}
const createRateLimiter = (maxRequests = 1000, windowMs = 3600000) => {
return rateLimit({
windowMs, // 1 hour default
max: maxRequests,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Use API key if available, otherwise IP
return req.apiKeyInfo?.id || req.ip;
},
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests',
message: 'Rate limit exceeded. Please try again later.',
retryAfter: Math.ceil(windowMs / 1000)
});
},
skip: (req) => {
// Skip rate limiting for health checks
return req.path === '/health';
}
});
};
module.exports = createRateLimiter;

View File

@@ -0,0 +1,47 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
// Ensure upload directories exist
const uploadDir = path.join(__dirname, '../../public/uploads/avatars');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// Generate unique filename: userId-timestamp.ext
const userId = req.user.id;
const timestamp = Date.now();
const ext = path.extname(file.originalname);
cb(null, `user-${userId}-${timestamp}${ext}`);
}
});
// File filter - only images
const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed (jpeg, jpg, png, gif, webp)'));
}
};
// Configure multer
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit
},
fileFilter: fileFilter
});
module.exports = upload;

View File

@@ -0,0 +1,209 @@
const express = require('express');
const router = express.Router();
const pool = require('../config/database');
// Get all alerts (with optional filters)
router.get('/', async (req, res) => {
try {
const { route_id, is_active, alert_type } = req.query;
let query = `
SELECT a.*, r.name as route_name, r.route_number, r.color
FROM alerts a
LEFT JOIN routes r ON a.route_id = r.id
WHERE 1=1
`;
const params = [];
let paramCount = 1;
if (route_id) {
query += ` AND a.route_id = $${paramCount}`;
params.push(route_id);
paramCount++;
}
if (is_active !== undefined) {
query += ` AND a.is_active = $${paramCount}`;
params.push(is_active === 'true');
paramCount++;
}
if (alert_type) {
query += ` AND a.alert_type = $${paramCount}`;
params.push(alert_type);
paramCount++;
}
query += ' ORDER BY a.start_time DESC';
const result = await pool.query(query, params);
res.json(result.rows);
} catch (error) {
console.error('Error fetching alerts:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get active alerts (current time between start_time and end_time)
router.get('/active', async (req, res) => {
try {
const { route_id } = req.query;
let query = `
SELECT a.*, r.name as route_name, r.route_number, r.color
FROM alerts a
LEFT JOIN routes r ON a.route_id = r.id
WHERE a.is_active = true
AND a.start_time <= NOW()
AND (a.end_time IS NULL OR a.end_time >= NOW())
`;
const params = [];
if (route_id) {
query += ' AND a.route_id = $1';
params.push(route_id);
}
query += ' ORDER BY a.start_time DESC';
const result = await pool.query(query, params);
res.json(result.rows);
} catch (error) {
console.error('Error fetching active alerts:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get alert by ID
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT a.*, r.name as route_name, r.route_number, r.color
FROM alerts a
LEFT JOIN routes r ON a.route_id = r.id
WHERE a.id = $1`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching alert:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Create new alert
router.post('/', async (req, res) => {
try {
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
// Validation
if (!alert_type || !title || !message || !start_time) {
return res.status(400).json({ error: 'Missing required fields' });
}
const validTypes = ['delay', 'cancellation', 'detour', 'info'];
if (!validTypes.includes(alert_type)) {
return res.status(400).json({ error: 'Invalid alert_type' });
}
const result = await pool.query(
`INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[route_id || null, alert_type, title, message, start_time, end_time || null, is_active !== false]
);
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating alert:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update alert
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
// Check if alert exists
const checkResult = await pool.query('SELECT id FROM alerts WHERE id = $1', [id]);
if (checkResult.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
// Validation
if (alert_type) {
const validTypes = ['delay', 'cancellation', 'detour', 'info'];
if (!validTypes.includes(alert_type)) {
return res.status(400).json({ error: 'Invalid alert_type' });
}
}
const result = await pool.query(
`UPDATE alerts
SET route_id = COALESCE($1, route_id),
alert_type = COALESCE($2, alert_type),
title = COALESCE($3, title),
message = COALESCE($4, message),
start_time = COALESCE($5, start_time),
end_time = $6,
is_active = COALESCE($7, is_active)
WHERE id = $8
RETURNING *`,
[route_id, alert_type, title, message, start_time, end_time, is_active, id]
);
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating alert:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete alert
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query('DELETE FROM alerts WHERE id = $1 RETURNING id', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
res.json({ message: 'Alert deleted successfully' });
} catch (error) {
console.error('Error deleting alert:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Deactivate expired alerts (utility endpoint)
router.post('/cleanup', async (req, res) => {
try {
const result = await pool.query(
`UPDATE alerts
SET is_active = false
WHERE is_active = true
AND end_time IS NOT NULL
AND end_time < NOW()
RETURNING id`
);
res.json({
message: 'Cleanup completed',
deactivated_count: result.rows.length
});
} catch (error) {
console.error('Error cleaning up alerts:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

78
backend/src/routes/api.js Normal file
View File

@@ -0,0 +1,78 @@
const express = require('express');
const router = express.Router();
// Controllers
const routesController = require('../controllers/routesController');
const stopsController = require('../controllers/stopsController');
const routeStopsController = require('../controllers/routeStopsController');
const schedulesController = require('../controllers/schedulesController');
const holidaysController = require('../controllers/holidaysController');
const etaController = require('../controllers/etaController');
const syncController = require('../controllers/syncController');
const telemetryController = require('../controllers/telemetryController');
// Routes
router.get('/routes', routesController.getAllRoutes.bind(routesController));
router.get('/routes/:id', routesController.getRouteById.bind(routesController));
router.post('/routes', routesController.createRoute.bind(routesController));
router.put('/routes/:id', routesController.updateRoute.bind(routesController));
router.delete('/routes/:id', routesController.deleteRoute.bind(routesController));
// Route Stops (привязка остановок к маршрутам)
router.get('/routes/:routeId/stops', routeStopsController.getRouteStops.bind(routeStopsController));
router.get('/routes/:routeId/available-stops', routeStopsController.getAvailableStops.bind(routeStopsController));
router.post('/routes/:routeId/stops', routeStopsController.addStopToRoute.bind(routeStopsController));
router.put('/routes/:routeId/stops/:routeStopId', routeStopsController.updateRouteStop.bind(routeStopsController));
router.delete('/routes/:routeId/stops/:routeStopId', routeStopsController.removeStopFromRoute.bind(routeStopsController));
router.put('/routes/:routeId/stops-reorder', routeStopsController.reorderRouteStops.bind(routeStopsController));
// Route Schedules (расписания маршрутов)
router.get('/routes/:routeId/schedules', schedulesController.getRouteSchedules.bind(schedulesController));
router.get('/routes/:routeId/schedule/today', schedulesController.getTodaySchedule.bind(schedulesController));
router.post('/routes/:routeId/schedules/copy', schedulesController.copySchedule.bind(schedulesController));
// Schedules (общие)
router.get('/schedules/:id', schedulesController.getScheduleById.bind(schedulesController));
router.post('/schedules', schedulesController.createSchedule.bind(schedulesController));
router.put('/schedules/:id', schedulesController.updateSchedule.bind(schedulesController));
router.delete('/schedules/:id', schedulesController.deleteSchedule.bind(schedulesController));
// Holidays (праздничные дни)
router.get('/holidays', holidaysController.getAllHolidays.bind(holidaysController));
router.get('/holidays/upcoming', holidaysController.getUpcomingHolidays.bind(holidaysController));
router.get('/holidays/year/:year', holidaysController.getHolidaysForYear.bind(holidaysController));
router.get('/holidays/check/:date', holidaysController.checkHoliday.bind(holidaysController));
router.post('/holidays', holidaysController.createHoliday.bind(holidaysController));
router.put('/holidays/:id', holidaysController.updateHoliday.bind(holidaysController));
router.delete('/holidays/:id', holidaysController.deleteHoliday.bind(holidaysController));
// Stops
router.get('/stops', stopsController.getAllStops.bind(stopsController));
router.get('/stops/nearby', stopsController.getNearbyStops.bind(stopsController));
router.get('/stops/:id', stopsController.getStopById.bind(stopsController));
router.post('/stops', stopsController.createStop.bind(stopsController));
router.put('/stops/:id', stopsController.updateStop.bind(stopsController));
router.delete('/stops/:id', stopsController.deleteStop.bind(stopsController));
// ETA
router.post('/eta/calculate', etaController.calculateEta.bind(etaController));
router.get('/eta/stop/:stopId', etaController.getStopEtas.bind(etaController));
// Realtime GPS / Telemetry
router.post('/telemetry/ingest', telemetryController.ingest.bind(telemetryController));
router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController));
router.get('/realtime/vehicles/:vehicleId', telemetryController.getVehicleLiveState.bind(telemetryController));
// Sync
router.get('/sync', syncController.sync.bind(syncController));
router.get('/sync/status', syncController.getSyncStatus.bind(syncController));
// Alerts
const alertsRouter = require('./alerts');
router.use('/alerts', alertsRouter);
// Users management (requires JWT auth, handled in routes/users.js)
const usersRouter = require('./users');
router.use('/users', usersRouter);
module.exports = router;

View File

@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const { authenticateToken } = require('../middleware/auth');
// Public routes
router.post('/login', authController.login.bind(authController));
router.post('/refresh', authController.refresh.bind(authController));
router.post('/logout', authController.logout.bind(authController));
// Protected routes
router.get('/me', authenticateToken, authController.me.bind(authController));
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
module.exports = router;

View File

@@ -0,0 +1,29 @@
const express = require('express');
const router = express.Router();
const uploadController = require('../controllers/uploadController');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const upload = require('../middleware/upload');
// User routes - authenticated users can upload/delete their own avatar
router.post(
'/avatar',
authenticateToken,
upload.single('avatar'),
uploadController.uploadAvatar.bind(uploadController)
);
router.delete(
'/avatar',
authenticateToken,
uploadController.deleteAvatar.bind(uploadController)
);
// Admin routes - admins can delete any user's avatar
router.delete(
'/avatar/:userId',
authenticateToken,
requireAdmin,
uploadController.deleteUserAvatar.bind(uploadController)
);
module.exports = router;

View File

@@ -0,0 +1,17 @@
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
// All routes require authentication and admin role
router.use(authenticateToken);
router.use(requireAdmin);
router.get('/', usersController.getAllUsers.bind(usersController));
router.get('/:id', usersController.getUserById.bind(usersController));
router.post('/', usersController.createUser.bind(usersController));
router.put('/:id', usersController.updateUser.bind(usersController));
router.delete('/:id', usersController.deleteUser.bind(usersController));
router.post('/:id/reset-password', usersController.resetPassword.bind(usersController));
module.exports = router;

View File

@@ -0,0 +1,30 @@
const fs = require('fs').promises;
const path = require('path');
const pool = require('../config/database');
const logger = require('../config/logger');
let ensurePromise = null;
async function ensureRealtimeSchema() {
if (ensurePromise) {
return ensurePromise;
}
ensurePromise = (async () => {
const migrationPath = path.join(__dirname, '../../migrations/005_realtime_gps.sql');
const sql = await fs.readFile(migrationPath, 'utf8');
await pool.query(sql);
logger.info('Realtime GPS schema ensured');
})().catch((error) => {
ensurePromise = null;
throw error;
});
return ensurePromise;
}
module.exports = {
ensureRealtimeSchema
};

View File

@@ -0,0 +1,304 @@
const WebSocket = require('ws');
const logger = require('../config/logger');
const telemetryService = require('./telemetryService');
const { verifyJwtToken, getApiKeyInfo } = require('../middleware/auth');
const routeSubscriptions = new Map();
let webSocketServer = null;
let heartbeatInterval = null;
function sendMessage(ws, payload) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify(payload));
}
function addRouteSubscription(ws, routeId) {
const routeKey = String(routeId);
if (!routeSubscriptions.has(routeKey)) {
routeSubscriptions.set(routeKey, new Set());
}
routeSubscriptions.get(routeKey).add(ws);
ws.subscriptions.add(routeKey);
}
function removeRouteSubscription(ws, routeId) {
const routeKey = String(routeId);
const subscribers = routeSubscriptions.get(routeKey);
if (!subscribers) {
return;
}
subscribers.delete(ws);
ws.subscriptions.delete(routeKey);
if (subscribers.size === 0) {
routeSubscriptions.delete(routeKey);
}
}
function cleanupSocket(ws) {
if (!ws.subscriptions) {
return;
}
for (const routeId of ws.subscriptions) {
removeRouteSubscription(ws, routeId);
}
}
async function handleAuthMessage(ws, payload) {
const token = typeof payload.token === 'string' ? payload.token.trim() : '';
const apiKey = typeof payload.apiKey === 'string' ? payload.apiKey.trim() : '';
if (!token && !apiKey) {
sendMessage(ws, {
type: 'error',
message: 'Provide token or apiKey in auth message'
});
return;
}
if (token) {
const user = verifyJwtToken(token);
if (!user) {
sendMessage(ws, {
type: 'error',
message: 'Invalid or expired token'
});
return;
}
ws.auth = {
type: 'jwt',
user
};
sendMessage(ws, {
type: 'auth:ok',
authType: 'jwt',
user: {
id: user.id,
username: user.username,
role: user.role
}
});
return;
}
const apiKeyInfo = await getApiKeyInfo(apiKey, true);
if (!apiKeyInfo) {
sendMessage(ws, {
type: 'error',
message: 'Invalid API key'
});
return;
}
ws.auth = {
type: 'apiKey',
apiKeyInfo
};
sendMessage(ws, {
type: 'auth:ok',
authType: 'apiKey',
appName: apiKeyInfo.app_name
});
}
async function handleSubscribeRoute(ws, payload) {
if (!ws.auth) {
sendMessage(ws, {
type: 'error',
message: 'Authenticate first'
});
return;
}
const routeId = Number.parseInt(payload.routeId, 10);
if (!Number.isInteger(routeId) || routeId <= 0) {
sendMessage(ws, {
type: 'error',
message: 'routeId must be a positive integer'
});
return;
}
addRouteSubscription(ws, routeId);
const vehicles = await telemetryService.getRouteLiveVehicles(routeId).catch((error) => {
logger.error('Failed to build realtime route snapshot:', error);
return [];
});
sendMessage(ws, {
type: 'subscription:ok',
routeId
});
sendMessage(ws, {
type: 'snapshot:route',
routeId,
vehicles
});
}
function handleUnsubscribeRoute(ws, payload) {
const routeId = Number.parseInt(payload.routeId, 10);
if (!Number.isInteger(routeId) || routeId <= 0) {
sendMessage(ws, {
type: 'error',
message: 'routeId must be a positive integer'
});
return;
}
removeRouteSubscription(ws, routeId);
sendMessage(ws, {
type: 'subscription:removed',
routeId
});
}
function broadcastToRoute(routeId, payload) {
const subscribers = routeSubscriptions.get(String(routeId));
if (!subscribers || subscribers.size === 0) {
return;
}
for (const ws of subscribers) {
sendMessage(ws, payload);
}
}
function attachRealtimeServer(server) {
if (webSocketServer) {
return webSocketServer;
}
webSocketServer = new WebSocket.Server({
server,
path: '/ws/realtime'
});
webSocketServer.on('connection', (ws, request) => {
ws.isAlive = true;
ws.subscriptions = new Set();
ws.auth = null;
logger.info(`Realtime websocket connected from ${request.socket.remoteAddress}`);
sendMessage(ws, {
type: 'welcome',
message: 'Realtime GPS websocket connected'
});
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', async (rawMessage) => {
try {
const payload = JSON.parse(rawMessage.toString());
switch (payload.type) {
case 'auth':
await handleAuthMessage(ws, payload);
break;
case 'subscribe:route':
await handleSubscribeRoute(ws, payload);
break;
case 'unsubscribe:route':
handleUnsubscribeRoute(ws, payload);
break;
case 'ping':
sendMessage(ws, { type: 'pong', timestamp: new Date().toISOString() });
break;
default:
sendMessage(ws, {
type: 'error',
message: `Unsupported message type: ${payload.type}`
});
}
} catch (error) {
logger.error('Realtime websocket message error:', error);
sendMessage(ws, {
type: 'error',
message: 'Invalid realtime message payload'
});
}
});
ws.on('close', () => {
cleanupSocket(ws);
logger.info('Realtime websocket disconnected');
});
ws.on('error', (error) => {
logger.error('Realtime websocket error:', error);
cleanupSocket(ws);
});
});
heartbeatInterval = setInterval(() => {
if (!webSocketServer) {
return;
}
webSocketServer.clients.forEach((ws) => {
if (ws.isAlive === false) {
cleanupSocket(ws);
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
webSocketServer.on('close', () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
});
logger.info('Realtime websocket server attached at /ws/realtime');
return webSocketServer;
}
function closeRealtimeServer() {
if (!webSocketServer) {
return;
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
webSocketServer.close();
webSocketServer = null;
}
module.exports = {
attachRealtimeServer,
broadcastToRoute,
closeRealtimeServer
};

View File

@@ -0,0 +1,386 @@
const pool = require('../config/database');
const logger = require('../config/logger');
const { ensureRealtimeSchema } = require('./realtimeSchema');
const DEFAULT_STALE_AFTER_SECONDS = 180;
function createHttpError(status, message) {
const error = new Error(message);
error.status = status;
return error;
}
function parseCoordinate(value, fieldName) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) {
throw createHttpError(400, `${fieldName} must be a valid number`);
}
return parsed;
}
function parseOptionalNumber(value, fieldName) {
if (value === undefined || value === null || value === '') {
return null;
}
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) {
throw createHttpError(400, `${fieldName} must be a valid number`);
}
return parsed;
}
class TelemetryService {
async ingestTelemetry(payload) {
await ensureRealtimeSchema();
const lat = parseCoordinate(payload.lat, 'lat');
const lon = parseCoordinate(payload.lon, 'lon');
if (lat < -90 || lat > 90) {
throw createHttpError(400, 'lat must be between -90 and 90');
}
if (lon < -180 || lon > 180) {
throw createHttpError(400, 'lon must be between -180 and 180');
}
const speedKmh = parseOptionalNumber(payload.speedKmh, 'speedKmh');
const heading = parseOptionalNumber(payload.heading, 'heading');
const accuracyMeters = parseOptionalNumber(payload.accuracyMeters, 'accuracyMeters');
if (heading !== null && (heading < 0 || heading > 360)) {
throw createHttpError(400, 'heading must be between 0 and 360');
}
if (!payload.vehicleId && !payload.vehicleNumber) {
throw createHttpError(400, 'vehicleId or vehicleNumber is required');
}
const route = await this.getRoute(payload.routeId);
const vehicle = await this.resolveVehicle({
vehicleId: payload.vehicleId,
vehicleNumber: payload.vehicleNumber,
vehicleType: payload.vehicleType,
route
});
const effectiveRouteId = route?.id || vehicle.route_id;
if (!effectiveRouteId) {
throw createHttpError(400, 'routeId is required when the vehicle is not already assigned to a route');
}
if (vehicle.route_id !== effectiveRouteId) {
await pool.query(
'UPDATE vehicles SET route_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[effectiveRouteId, vehicle.id]
);
}
const recordedAt = payload.recordedAt ? new Date(payload.recordedAt) : new Date();
if (Number.isNaN(recordedAt.getTime())) {
throw createHttpError(400, 'recordedAt must be a valid ISO datetime string');
}
const sourceType = payload.sourceType || 'manual';
const sourceRef = payload.sourceRef || null;
const rawPayload = payload.rawPayload || payload;
await pool.query(
`INSERT INTO telemetry_samples (
vehicle_id,
route_id,
source_type,
source_ref,
location,
speed_kmh,
heading,
accuracy_meters,
recorded_at,
raw_payload
) VALUES (
$1,
$2,
$3,
$4,
ST_SetSRID(ST_MakePoint($6, $5), 4326)::geography,
$7,
$8,
$9,
$10,
$11::jsonb
)`,
[
vehicle.id,
effectiveRouteId,
sourceType,
sourceRef,
lat,
lon,
speedKmh,
heading,
accuracyMeters,
recordedAt.toISOString(),
JSON.stringify(rawPayload)
]
);
await pool.query(
`INSERT INTO vehicle_live_state (
vehicle_id,
route_id,
location,
speed_kmh,
heading,
accuracy_meters,
source_type,
source_ref,
last_seen_at
) VALUES (
$1,
$2,
ST_SetSRID(ST_MakePoint($4, $3), 4326)::geography,
$5,
$6,
$7,
$8,
$9,
$10
)
ON CONFLICT (vehicle_id) DO UPDATE SET
route_id = EXCLUDED.route_id,
location = EXCLUDED.location,
speed_kmh = EXCLUDED.speed_kmh,
heading = EXCLUDED.heading,
accuracy_meters = EXCLUDED.accuracy_meters,
source_type = EXCLUDED.source_type,
source_ref = EXCLUDED.source_ref,
last_seen_at = EXCLUDED.last_seen_at,
updated_at = CURRENT_TIMESTAMP`,
[
vehicle.id,
effectiveRouteId,
lat,
lon,
speedKmh,
heading,
accuracyMeters,
sourceType,
sourceRef,
recordedAt.toISOString()
]
);
const liveState = await this.getVehicleLiveState(vehicle.id, DEFAULT_STALE_AFTER_SECONDS);
logger.info(`Telemetry ingested for vehicle ${vehicle.id} on route ${effectiveRouteId}`);
return liveState;
}
async getRouteLiveVehicles(routeId, options = {}) {
await ensureRealtimeSchema();
const routeIdInt = Number.parseInt(routeId, 10);
if (!Number.isInteger(routeIdInt) || routeIdInt <= 0) {
throw createHttpError(400, 'routeId must be a positive integer');
}
const staleAfterSeconds = Number.parseInt(
options.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS,
10
);
const includeStale = Boolean(options.includeStale);
let query = `
SELECT
vls.vehicle_id,
v.vehicle_number,
v.registration,
v.type AS vehicle_type,
v.capacity,
vls.route_id,
r.route_number,
r.name AS route_name,
r.color AS route_color,
ST_Y(vls.location::geometry) AS latitude,
ST_X(vls.location::geometry) AS longitude,
vls.speed_kmh,
vls.heading,
vls.accuracy_meters,
vls.source_type,
vls.source_ref,
vls.last_seen_at,
vls.updated_at,
CASE
WHEN vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second') THEN true
ELSE false
END AS is_online,
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - vls.last_seen_at))))::int AS seconds_since_update,
CASE WHEN COALESCE(vls.speed_kmh, 0) >= 1 THEN true ELSE false END AS is_moving
FROM vehicle_live_state vls
JOIN vehicles v ON v.id = vls.vehicle_id
JOIN routes r ON r.id = vls.route_id
WHERE vls.route_id = $1
AND v.is_active = true
`;
if (!includeStale) {
query += `
AND vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second')
`;
}
query += `
ORDER BY vls.last_seen_at DESC, v.vehicle_number ASC
`;
const result = await pool.query(query, [routeIdInt, staleAfterSeconds]);
return result.rows;
}
async getVehicleLiveState(vehicleId, staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS) {
await ensureRealtimeSchema();
const vehicleIdInt = Number.parseInt(vehicleId, 10);
if (!Number.isInteger(vehicleIdInt) || vehicleIdInt <= 0) {
throw createHttpError(400, 'vehicleId must be a positive integer');
}
const result = await pool.query(
`SELECT
vls.vehicle_id,
v.vehicle_number,
v.registration,
v.type AS vehicle_type,
v.capacity,
vls.route_id,
r.route_number,
r.name AS route_name,
r.color AS route_color,
ST_Y(vls.location::geometry) AS latitude,
ST_X(vls.location::geometry) AS longitude,
vls.speed_kmh,
vls.heading,
vls.accuracy_meters,
vls.source_type,
vls.source_ref,
vls.last_seen_at,
vls.updated_at,
CASE
WHEN vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second') THEN true
ELSE false
END AS is_online,
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - vls.last_seen_at))))::int AS seconds_since_update,
CASE WHEN COALESCE(vls.speed_kmh, 0) >= 1 THEN true ELSE false END AS is_moving
FROM vehicle_live_state vls
JOIN vehicles v ON v.id = vls.vehicle_id
JOIN routes r ON r.id = vls.route_id
WHERE vls.vehicle_id = $1
LIMIT 1`,
[vehicleIdInt, staleAfterSeconds]
);
if (result.rows.length === 0) {
throw createHttpError(404, 'Live state not found for this vehicle');
}
return result.rows[0];
}
async getRoute(routeId) {
if (routeId === undefined || routeId === null || routeId === '') {
return null;
}
const routeIdInt = Number.parseInt(routeId, 10);
if (!Number.isInteger(routeIdInt) || routeIdInt <= 0) {
throw createHttpError(400, 'routeId must be a positive integer');
}
const result = await pool.query(
'SELECT * FROM routes WHERE id = $1 AND is_active = true',
[routeIdInt]
);
if (result.rows.length === 0) {
throw createHttpError(404, 'Route not found');
}
return result.rows[0];
}
async resolveVehicle({ vehicleId, vehicleNumber, vehicleType, route }) {
if (vehicleId !== undefined && vehicleId !== null && vehicleId !== '') {
const vehicleIdInt = Number.parseInt(vehicleId, 10);
if (!Number.isInteger(vehicleIdInt) || vehicleIdInt <= 0) {
throw createHttpError(400, 'vehicleId must be a positive integer');
}
const result = await pool.query(
'SELECT * FROM vehicles WHERE id = $1',
[vehicleIdInt]
);
if (result.rows.length === 0) {
throw createHttpError(404, 'Vehicle not found');
}
if (!result.rows[0].is_active) {
throw createHttpError(400, 'Vehicle is inactive');
}
return result.rows[0];
}
const cleanedVehicleNumber = typeof vehicleNumber === 'string'
? vehicleNumber.trim()
: '';
if (!cleanedVehicleNumber) {
throw createHttpError(400, 'vehicleNumber must be a non-empty string');
}
const existing = await pool.query(
'SELECT * FROM vehicles WHERE vehicle_number = $1 LIMIT 1',
[cleanedVehicleNumber]
);
if (existing.rows.length > 0) {
if (!existing.rows[0].is_active) {
throw createHttpError(400, 'Vehicle is inactive');
}
return existing.rows[0];
}
if (!route) {
throw createHttpError(400, 'routeId is required to auto-create a vehicle by vehicleNumber');
}
const resolvedType = vehicleType || route.type || 'bus';
const created = await pool.query(
`INSERT INTO vehicles (vehicle_number, type, route_id, is_active)
VALUES ($1, $2, $3, true)
RETURNING *`,
[cleanedVehicleNumber, resolvedType, route.id]
);
logger.info(`Auto-created vehicle ${cleanedVehicleNumber} for route ${route.id}`);
return created.rows[0];
}
}
module.exports = new TelemetryService();

View File

@@ -0,0 +1,34 @@
const fs = require('fs').promises;
const path = require('path');
const pool = require('../config/database');
async function runMigrations() {
const migrationsDir = path.join(__dirname, '../../migrations');
const migrationFiles = (await fs.readdir(migrationsDir))
.filter((file) => file.endsWith('.sql'))
.sort();
const client = await pool.connect();
try {
for (const file of migrationFiles) {
const fullPath = path.join(migrationsDir, file);
const sql = await fs.readFile(fullPath, 'utf8');
console.log(`Applying migration: ${file}`);
await client.query(sql);
}
console.log('All migrations applied successfully');
} finally {
client.release();
await pool.end();
}
}
runMigrations().catch(async (error) => {
console.error('Migration failed:', error);
await pool.end();
process.exit(1);
});