Initial commit
This commit is contained in:
24
backend/Dockerfile
Normal file
24
backend/Dockerfile
Normal 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"]
|
||||
187
backend/migrations/001_init.sql
Normal file
187
backend/migrations/001_init.sql
Normal 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;
|
||||
86
backend/migrations/002_holidays.sql
Normal file
86
backend/migrations/002_holidays.sql
Normal 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();
|
||||
44
backend/migrations/003_users_auth.sql
Normal file
44
backend/migrations/003_users_auth.sql
Normal 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);
|
||||
7
backend/migrations/004_users_avatars.sql
Normal file
7
backend/migrations/004_users_avatars.sql
Normal 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';
|
||||
57
backend/migrations/005_realtime_gps.sql
Normal file
57
backend/migrations/005_realtime_gps.sql
Normal 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
40
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
backend/scripts/create-admin.js
Normal file
29
backend/scripts/create-admin.js
Normal 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();
|
||||
25
backend/src/config/database.js
Normal file
25
backend/src/config/database.js
Normal 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;
|
||||
36
backend/src/config/logger.js
Normal file
36
backend/src/config/logger.js
Normal 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;
|
||||
21
backend/src/config/redis.js
Normal file
21
backend/src/config/redis.js
Normal 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;
|
||||
232
backend/src/controllers/authController.js
Normal file
232
backend/src/controllers/authController.js
Normal 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();
|
||||
271
backend/src/controllers/etaController.js
Normal file
271
backend/src/controllers/etaController.js
Normal 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();
|
||||
223
backend/src/controllers/holidaysController.js
Normal file
223
backend/src/controllers/holidaysController.js
Normal 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();
|
||||
282
backend/src/controllers/routeStopsController.js
Normal file
282
backend/src/controllers/routeStopsController.js
Normal 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();
|
||||
181
backend/src/controllers/routesController.js
Normal file
181
backend/src/controllers/routesController.js
Normal 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();
|
||||
348
backend/src/controllers/schedulesController.js
Normal file
348
backend/src/controllers/schedulesController.js
Normal 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();
|
||||
237
backend/src/controllers/stopsController.js
Normal file
237
backend/src/controllers/stopsController.js
Normal 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();
|
||||
250
backend/src/controllers/syncController.js
Normal file
250
backend/src/controllers/syncController.js
Normal 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();
|
||||
84
backend/src/controllers/telemetryController.js
Normal file
84
backend/src/controllers/telemetryController.js
Normal 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();
|
||||
143
backend/src/controllers/uploadController.js
Normal file
143
backend/src/controllers/uploadController.js
Normal 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();
|
||||
182
backend/src/controllers/usersController.js
Normal file
182
backend/src/controllers/usersController.js
Normal 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
155
backend/src/index.js
Normal 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;
|
||||
115
backend/src/middleware/auth.js
Normal file
115
backend/src/middleware/auth.js
Normal 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
|
||||
};
|
||||
66
backend/src/middleware/rateLimiter.js
Normal file
66
backend/src/middleware/rateLimiter.js
Normal 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;
|
||||
47
backend/src/middleware/upload.js
Normal file
47
backend/src/middleware/upload.js
Normal 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;
|
||||
209
backend/src/routes/alerts.js
Normal file
209
backend/src/routes/alerts.js
Normal 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
78
backend/src/routes/api.js
Normal 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;
|
||||
15
backend/src/routes/auth.js
Normal file
15
backend/src/routes/auth.js
Normal 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;
|
||||
29
backend/src/routes/upload.js
Normal file
29
backend/src/routes/upload.js
Normal 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;
|
||||
17
backend/src/routes/users.js
Normal file
17
backend/src/routes/users.js
Normal 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;
|
||||
30
backend/src/services/realtimeSchema.js
Normal file
30
backend/src/services/realtimeSchema.js
Normal 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
|
||||
};
|
||||
304
backend/src/services/realtimeService.js
Normal file
304
backend/src/services/realtimeService.js
Normal 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
|
||||
};
|
||||
386
backend/src/services/telemetryService.js
Normal file
386
backend/src/services/telemetryService.js
Normal 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();
|
||||
34
backend/src/utils/migrate.js
Normal file
34
backend/src/utils/migrate.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user