Initial commit
commit
3565390a49
@ -0,0 +1,14 @@
|
||||
# Database
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
|
||||
# API Configuration
|
||||
API_KEY_SALT=generate_random_salt_with_openssl
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
|
||||
# Frontend
|
||||
API_URL=http://localhost:3000/api/v1
|
||||
|
||||
# Production settings (uncomment for production)
|
||||
# NODE_ENV=production
|
||||
# CORS_ORIGIN=https://admin.yourdomain.com
|
||||
# API_URL=https://api.yourdomain.com/api/v1
|
||||
@ -0,0 +1,43 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tgz
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Docker volumes
|
||||
postgres_data/
|
||||
redis_data/
|
||||
|
||||
# SSL certificates
|
||||
nginx/ssl/
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
.cache/
|
||||
@ -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"]
|
||||
@ -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;
|
||||
@ -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();
|
||||
@ -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);
|
||||
@ -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';
|
||||
@ -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 $$;
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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;
|
||||
@ -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
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
|
||||
};
|
||||
@ -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
|
||||
};
|
||||
@ -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();
|
||||
@ -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);
|
||||
});
|
||||
@ -0,0 +1,96 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:15-3.3
|
||||
container_name: polotsk-transit-db
|
||||
environment:
|
||||
POSTGRES_DB: polotsk_transit
|
||||
POSTGRES_USER: transit_admin
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backend/migrations:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: polotsk-transit-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: polotsk-transit-api
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgresql://transit_admin:${DB_PASSWORD:-change_me_in_production}@postgres:5432/polotsk_transit
|
||||
REDIS_URL: redis://redis:6379
|
||||
API_KEY_SALT: ${API_KEY_SALT:-generate_random_salt}
|
||||
JWT_SECRET: ${JWT_SECRET:-change_me_in_production_use_random_string}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./backend/logs:/app/logs
|
||||
- avatars_data:/app/public/uploads/avatars
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1}
|
||||
container_name: polotsk-transit-admin
|
||||
environment:
|
||||
VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1}
|
||||
ports:
|
||||
- "3001:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: polotsk-transit-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
avatars_data:
|
||||
|
||||
networks:
|
||||
transit-network:
|
||||
driver: bridge
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:3000/api/v1
|
||||
VITE_API_KEY=dev_key_12345678
|
||||
@ -0,0 +1,26 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_API_URL=http://localhost:3000/api/v1
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/bus-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Polotsk Transit Admin Panel" />
|
||||
<title>Polotsk Transit Admin</title>
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1000;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Always revalidate the HTML shell so the app picks up the newest hashed assets
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "polotsk-transit-admin",
|
||||
"version": "1.0.0",
|
||||
"description": "Admin panel for Polotsk Transit API",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,281 @@
|
||||
import { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom';
|
||||
import { Bus, MapPin, Calendar, Settings, Gift, Menu, X, AlertCircle, Users, UserCircle, LogOut } from 'lucide-react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { Avatar } from './components/AvatarUpload';
|
||||
|
||||
// Pages
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import RoutesPage from './pages/RoutesPage';
|
||||
import RouteEditorPage from './pages/RouteEditorPage';
|
||||
import StopsPage from './pages/StopsPage';
|
||||
import SchedulesPage from './pages/SchedulesPage';
|
||||
import HolidaysPage from './pages/HolidaysPage';
|
||||
import AlertsPage from './pages/AlertsPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8 pb-20 lg:pb-8">
|
||||
<AnimatedRoutes />
|
||||
</main>
|
||||
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedRoutes() {
|
||||
const location = useLocation();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
return (
|
||||
<div key={location.pathname} className="page-transition">
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/routes" element={<ProtectedRoute><RoutesPage /></ProtectedRoute>} />
|
||||
<Route path="/routes/:id/edit" element={<ProtectedRoute><RouteEditorPage /></ProtectedRoute>} />
|
||||
<Route path="/stops" element={<ProtectedRoute><StopsPage /></ProtectedRoute>} />
|
||||
<Route path="/schedules" element={<ProtectedRoute><SchedulesPage /></ProtectedRoute>} />
|
||||
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
|
||||
<Route path="/alerts" element={<ProtectedRoute><AlertsPage /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
{isAdmin && (
|
||||
<Route path="/users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
|
||||
)}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Navigation() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-14 sm:h-16">
|
||||
<div className="flex items-center">
|
||||
<Link to="/" className="flex-shrink-0 flex items-center group">
|
||||
<Bus className="h-7 w-7 sm:h-8 sm:w-8 text-primary-600 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span className="ml-2 text-lg sm:text-xl font-bold text-gray-900 hidden xs:inline">
|
||||
Polotsk Transit
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden lg:ml-6 lg:flex lg:space-x-6">
|
||||
<NavLink to="/" icon={<Settings className="h-4 w-4" />}>Dashboard</NavLink>
|
||||
<NavLink to="/routes" icon={<Bus className="h-4 w-4" />}>Маршруты</NavLink>
|
||||
<NavLink to="/stops" icon={<MapPin className="h-4 w-4" />}>Остановки</NavLink>
|
||||
<NavLink to="/schedules" icon={<Calendar className="h-4 w-4" />}>Расписания</NavLink>
|
||||
<NavLink to="/alerts" icon={<AlertCircle className="h-4 w-4" />}>Уведомления</NavLink>
|
||||
{isAdmin && (
|
||||
<NavLink to="/users" icon={<Users className="h-4 w-4" />}>Пользователи</NavLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop User Menu */}
|
||||
<div className="hidden lg:flex items-center">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Avatar src={user?.avatar_url} alt={user?.username} size="sm" />
|
||||
<span className="text-sm font-medium text-gray-700">{user?.username}</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1">
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<UserCircle className="h-4 w-4" />
|
||||
Профиль
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { logout(); setShowUserMenu(false); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<div className="relative w-6 h-6">
|
||||
<X className={`h-6 w-6 absolute transition-all duration-300 ${
|
||||
mobileMenuOpen ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'
|
||||
}`} />
|
||||
<Menu className={`h-6 w-6 absolute transition-all duration-300 ${
|
||||
mobileMenuOpen ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'
|
||||
}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu dropdown */}
|
||||
<div className={`lg:hidden border-t border-gray-200 bg-white overflow-hidden transition-all duration-300 ease-out ${
|
||||
mobileMenuOpen ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}>
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
<MobileNavLink to="/" icon={<Settings className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={0}>
|
||||
Dashboard
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/routes" icon={<Bus className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={1}>
|
||||
Маршруты
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/stops" icon={<MapPin className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={2}>
|
||||
Остановки
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/schedules" icon={<Calendar className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={3}>
|
||||
Расписания
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/alerts" icon={<AlertCircle className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={4}>
|
||||
Уведомления
|
||||
</MobileNavLink>
|
||||
{isAdmin && (
|
||||
<MobileNavLink to="/users" icon={<Users className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={5}>
|
||||
Пользователи
|
||||
</MobileNavLink>
|
||||
)}
|
||||
<MobileNavLink to="/profile" icon={<UserCircle className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={6}>
|
||||
Профиль
|
||||
</MobileNavLink>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileBottomNav() {
|
||||
const location = useLocation();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Settings, label: 'Главная' },
|
||||
{ to: '/routes', icon: Bus, label: 'Маршруты' },
|
||||
{ to: '/stops', icon: MapPin, label: 'Остановки' },
|
||||
{ to: '/alerts', icon: AlertCircle, label: 'Уведомл.' },
|
||||
isAdmin
|
||||
? { to: '/users', icon: Users, label: 'Пользов.' }
|
||||
: { to: '/profile', icon: UserCircle, label: 'Профиль' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 lg:hidden z-50 safe-bottom">
|
||||
<div className="flex justify-around items-center h-16">
|
||||
{navItems.map(({ to, icon: Icon, label }) => {
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`flex flex-col items-center justify-center flex-1 h-full px-1 transition-all duration-200 ${
|
||||
isActive
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 transition-transform ${isActive ? 'scale-110 -translate-y-0.5' : ''}`} />
|
||||
<span className="text-xs mt-1 truncate">{label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-8 h-0.5 bg-primary-600 rounded-b-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ to, icon, children }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-primary-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-primary-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5">{icon}</span>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavLink({ to, icon, children, onClick, delay = 0 }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={`flex items-center px-3 py-3 rounded-lg text-base font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
style={{ animationDelay: `${delay * 50}ms` }}
|
||||
>
|
||||
<span className="mr-3">{icon}</span>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@ -0,0 +1,260 @@
|
||||
import { Loader2, CheckCircle, AlertCircle, X } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Animated Loading Spinner
|
||||
export function LoadingSpinner({ size = 'md', className = '' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-5 w-5',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
<Loader2 className={`${sizeClasses[size]} text-primary-600 animate-spin`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full page loading
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="mt-4 text-gray-500 animate-pulse">Загрузка...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader for cards
|
||||
export function CardSkeleton({ count = 1 }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="card" style={{ animationDelay: `${i * 100}ms` }}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="skeleton w-12 h-12 rounded-lg" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="skeleton h-4 w-3/4 mb-2 rounded" />
|
||||
<div className="skeleton h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="skeleton h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader for list items
|
||||
export function ListSkeleton({ count = 5 }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center p-3 bg-gray-50 rounded-lg"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<div className="skeleton w-8 h-8 rounded-full mr-3" />
|
||||
<div className="flex-1">
|
||||
<div className="skeleton h-4 w-3/4 mb-1 rounded" />
|
||||
<div className="skeleton h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toast notification component
|
||||
export function Toast({ message, type = 'success', onClose, duration = 3000 }) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
handleClose();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const typeStyles = {
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
warning: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800'
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="h-5 w-5 text-green-500" />,
|
||||
error: <AlertCircle className="h-5 w-5 text-red-500" />,
|
||||
warning: <AlertCircle className="h-5 w-5 text-amber-500" />,
|
||||
info: <AlertCircle className="h-5 w-5 text-blue-500" />
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-20 lg:bottom-8 left-4 right-4 sm:left-auto sm:right-8 sm:w-80 z-50
|
||||
${isExiting ? 'slide-exit' : 'slide-enter'}`}
|
||||
>
|
||||
<div className={`flex items-center p-4 rounded-lg border shadow-lg ${typeStyles[type]}`}>
|
||||
<div className="flex-shrink-0 success-check">
|
||||
{icons[type]}
|
||||
</div>
|
||||
<p className="ml-3 text-sm font-medium flex-1">{message}</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="ml-2 p-1 rounded hover:bg-black/10 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Animated counter component
|
||||
export function AnimatedCounter({ value, duration = 1000 }) {
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof value !== 'number') {
|
||||
setDisplayValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
let startTime;
|
||||
const startValue = displayValue;
|
||||
const endValue = value;
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / duration, 1);
|
||||
|
||||
// Ease out
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||
const currentValue = Math.round(startValue + (endValue - startValue) * easeOut);
|
||||
|
||||
setDisplayValue(currentValue);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [value, duration]);
|
||||
|
||||
return <span className="tabular-nums">{displayValue}</span>;
|
||||
}
|
||||
|
||||
// Animated presence wrapper
|
||||
export function AnimatedPresence({ children, show, className = '' }) {
|
||||
const [shouldRender, setShouldRender] = useState(show);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) setShouldRender(true);
|
||||
}, [show]);
|
||||
|
||||
const handleAnimationEnd = () => {
|
||||
if (!show) setShouldRender(false);
|
||||
};
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${className} ${show ? 'slide-enter' : 'slide-exit'}`}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ripple button wrapper
|
||||
export function RippleButton({ children, onClick, className = '', ...props }) {
|
||||
const handleClick = (e) => {
|
||||
const button = e.currentTarget;
|
||||
const ripple = document.createElement('span');
|
||||
const rect = button.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
const x = e.clientX - rect.left - size / 2;
|
||||
const y = e.clientY - rect.top - size / 2;
|
||||
|
||||
ripple.style.width = ripple.style.height = `${size}px`;
|
||||
ripple.style.left = `${x}px`;
|
||||
ripple.style.top = `${y}px`;
|
||||
ripple.className = 'absolute bg-white/30 rounded-full animate-ping pointer-events-none';
|
||||
|
||||
button.appendChild(ripple);
|
||||
setTimeout(() => ripple.remove(), 500);
|
||||
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Pulsing dot indicator
|
||||
export function PulsingDot({ color = 'green', size = 'sm' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-4 h-4'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
green: 'bg-green-500',
|
||||
red: 'bg-red-500',
|
||||
amber: 'bg-amber-500',
|
||||
blue: 'bg-blue-500'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<span className={`${sizeClasses[size]} ${colorClasses[color]} rounded-full`} />
|
||||
<span className={`absolute ${sizeClasses[size]} ${colorClasses[color]} rounded-full animate-ping opacity-75`} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
export function ProgressBar({ value, max = 100, showLabel = false, className = '' }) {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-600 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<p className="text-xs text-gray-500 mt-1 text-right">{Math.round(percentage)}%</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Camera, Trash2, User, Loader2 } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { getApiBaseUrl, getApiOrigin } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const API_ORIGIN = getApiOrigin();
|
||||
|
||||
export default function AvatarUpload({ currentAvatar, onAvatarChange, size = 'large' }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'w-12 h-12',
|
||||
medium: 'w-20 h-20',
|
||||
large: 'w-32 h-32'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 'h-6 w-6',
|
||||
medium: 'h-10 w-10',
|
||||
large: 'h-16 w-16'
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Пожалуйста, выберите изображение');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('Размер файла не должен превышать 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const response = await axios.post(`${API_BASE_URL}/upload/avatar`, formData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
if (onAvatarChange) {
|
||||
onAvatarChange(response.data.avatar_url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
setError(error.response?.data?.error || 'Ошибка загрузки');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Удалить аватар?')) return;
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
await axios.delete(`${API_BASE_URL}/upload/avatar`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (onAvatarChange) {
|
||||
onAvatarChange(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
setError(error.response?.data?.error || 'Ошибка удаления');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarUrl = () => {
|
||||
if (!currentAvatar) return null;
|
||||
// If it's already a full URL, return it
|
||||
if (currentAvatar.startsWith('http')) return currentAvatar;
|
||||
// Otherwise, construct the URL
|
||||
return `${API_ORIGIN}${currentAvatar}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative group">
|
||||
<div className={`${sizeClasses[size]} rounded-full overflow-hidden bg-gray-100 border-2 border-gray-200 group-hover:border-primary-400 transition-colors`}>
|
||||
{currentAvatar ? (
|
||||
<img
|
||||
src={getAvatarUrl()}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextElementSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`${!currentAvatar ? 'flex' : 'hidden'} w-full h-full items-center justify-center bg-gray-200`}>
|
||||
<User className={`${iconSizes[size]} text-gray-400`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload overlay */}
|
||||
{!uploading && (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Загрузить фото"
|
||||
>
|
||||
<Camera className="h-8 w-8 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
|
||||
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
{currentAvatar && !uploading && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="absolute -bottom-2 -right-2 p-2 bg-red-600 text-white rounded-full shadow-lg hover:bg-red-700 transition-colors"
|
||||
title="Удалить фото"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
{!error && (
|
||||
<p className="mt-2 text-xs text-gray-500 text-center">
|
||||
JPG, PNG, GIF или WEBP<br />Максимум 5MB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple avatar display component
|
||||
export function Avatar({ src, alt, size = 'medium', className = '' }) {
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6',
|
||||
sm: 'w-8 h-8',
|
||||
medium: 'w-10 h-10',
|
||||
large: 'w-12 h-12',
|
||||
xl: 'w-16 h-16'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-4 w-4',
|
||||
medium: 'h-5 w-5',
|
||||
large: 'h-6 w-6',
|
||||
xl: 'h-8 w-8'
|
||||
};
|
||||
|
||||
const getAvatarUrl = () => {
|
||||
if (!src) return null;
|
||||
if (src.startsWith('http')) return src;
|
||||
return `${API_ORIGIN}${src}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} rounded-full overflow-hidden bg-gray-200 flex-shrink-0 ${className}`}>
|
||||
{src ? (
|
||||
<img
|
||||
src={getAvatarUrl()}
|
||||
alt={alt || 'Avatar'}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextElementSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`${!src ? 'flex' : 'hidden'} w-full h-full items-center justify-center bg-gray-300`}>
|
||||
<User className={`${iconSizes[size]} text-gray-500`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { PageLoader } from './AnimatedComponents';
|
||||
|
||||
export function ProtectedRoute({ children, requireAdmin = false }) {
|
||||
const { user, loading, isAdmin } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdmin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h2>
|
||||
<p className="text-gray-600">You don't have permission to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function RouteForm({ route, onSubmit, onCancel }) {
|
||||
const [formData, setFormData] = useState({
|
||||
route_number: '',
|
||||
name: '',
|
||||
type: 'bus',
|
||||
color: '#0066CC',
|
||||
description: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (route) {
|
||||
setFormData({
|
||||
route_number: route.route_number,
|
||||
name: route.name,
|
||||
type: route.type,
|
||||
color: route.color,
|
||||
description: route.description || ''
|
||||
});
|
||||
}
|
||||
}, [route]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-lg sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">
|
||||
{route ? 'Редактировать маршрут' : 'Новый маршрут'}
|
||||
</h2>
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600 p-1">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Номер маршрута *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="route_number"
|
||||
value={formData.route_number}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="1, 5A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Тип *</label>
|
||||
<select
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
>
|
||||
<option value="bus">Автобус</option>
|
||||
<option value="minibus">Маршрутка</option>
|
||||
<option value="trolleybus">Троллейбус</option>
|
||||
<option value="tram">Трамвай</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Название *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="Центр - Вокзал"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Цвет</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="h-11 w-16 rounded border border-gray-300 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, color: e.target.value }))}
|
||||
className="input flex-1"
|
||||
placeholder="#0066CC"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
rows="2"
|
||||
placeholder="Дополнительная информация"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn-primary w-full sm:w-auto">
|
||||
{route ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function StopForm({ stop, initialLocation, onSubmit, onCancel }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
address: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (stop) {
|
||||
setFormData({
|
||||
name: stop.name,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
address: stop.address || '',
|
||||
description: stop.description || ''
|
||||
});
|
||||
} else if (initialLocation) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
latitude: initialLocation.latitude.toFixed(6),
|
||||
longitude: initialLocation.longitude.toFixed(6)
|
||||
}));
|
||||
}
|
||||
}, [stop, initialLocation]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
...formData,
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude)
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-lg sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">
|
||||
{stop ? 'Редактировать остановку' : 'Новая остановка'}
|
||||
</h2>
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600 p-1">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="label">Название остановки *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="например: Площадь Свободы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Широта *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
name="latitude"
|
||||
value={formData.latitude}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="55.486900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Долгота *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
name="longitude"
|
||||
value={formData.longitude}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="28.785600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Адрес</label>
|
||||
<input
|
||||
type="text"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
placeholder="Улица, номер дома"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
rows="2"
|
||||
placeholder="Дополнительная информация"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn-primary w-full sm:w-auto">
|
||||
{stop ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,120 @@
|
||||
import { createContext, useState, useContext, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
if (!token || !refreshToken) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setUser(response.data);
|
||||
} catch (error) {
|
||||
// Try to refresh token
|
||||
try {
|
||||
const refreshResponse = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken
|
||||
});
|
||||
localStorage.setItem('accessToken', refreshResponse.data.accessToken);
|
||||
|
||||
// Retry getting user
|
||||
const userResponse = await axios.get(`${API_BASE_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${refreshResponse.data.accessToken}` }
|
||||
});
|
||||
setUser(userResponse.data);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, logout
|
||||
logout();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/login`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
const { user, accessToken, refreshToken } = response.data;
|
||||
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
|
||||
setUser(user);
|
||||
navigate('/');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || 'Login failed'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/auth/logout`, { refreshToken });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
setUser(null);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const getAccessToken = () => {
|
||||
return localStorage.getItem('accessToken');
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
getAccessToken,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: user?.role === 'admin'
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -0,0 +1,624 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Prevent zoom on input focus on iOS */
|
||||
input, select, textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
input, select, textarea {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-primary-700 hover:shadow-md hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-700 px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-gray-300 hover:shadow-sm hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-red-700 hover:shadow-md hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4 sm:p-6;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply hover:shadow-lg hover:-translate-y-1 hover:border-gray-300;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply border border-gray-300 rounded-lg px-3 py-2.5 w-full;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply text-base sm:text-sm;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
/* Mobile-friendly touch targets */
|
||||
.touch-target {
|
||||
@apply min-h-[44px] min-w-[44px];
|
||||
}
|
||||
|
||||
/* Page titles */
|
||||
.page-title {
|
||||
@apply text-2xl sm:text-3xl font-bold text-gray-900;
|
||||
}
|
||||
|
||||
/* Modal styles with animations */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply bg-white w-full sm:max-w-lg sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-content {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for bottom navigation */
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
/* Animated list items */
|
||||
.list-item-animated {
|
||||
@apply transition-all duration-200 ease-out;
|
||||
animation: fadeSlideIn 0.3s ease-out backwards;
|
||||
}
|
||||
|
||||
/* Stagger animation delays */
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
|
||||
/* Pulse animation for loading states */
|
||||
.pulse-soft {
|
||||
animation: pulseSoft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Shimmer loading effect */
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Icon animation */
|
||||
.icon-bounce {
|
||||
@apply transition-transform duration-200;
|
||||
}
|
||||
.icon-bounce:hover {
|
||||
animation: iconBounce 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Floating action button */
|
||||
.fab {
|
||||
@apply fixed bottom-20 right-4 lg:bottom-8 lg:right-8;
|
||||
@apply w-14 h-14 rounded-full shadow-lg;
|
||||
@apply flex items-center justify-center;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:shadow-xl hover:scale-110;
|
||||
@apply active:scale-95;
|
||||
@apply disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
animation: fabIn 0.4s ease-out;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
/* Secondary FAB */
|
||||
.fab-secondary {
|
||||
@apply fixed bottom-36 right-4 lg:bottom-24 lg:right-8;
|
||||
@apply w-12 h-12 rounded-full shadow-md;
|
||||
@apply flex items-center justify-center;
|
||||
@apply bg-white text-gray-700 border border-gray-200;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:shadow-lg hover:scale-105;
|
||||
@apply active:scale-95;
|
||||
animation: fabIn 0.4s ease-out 0.1s backwards;
|
||||
z-index: 39;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
@apply text-center py-12 px-4;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
@apply h-16 w-16 sm:h-20 sm:w-20 text-gray-300 mx-auto mb-4;
|
||||
animation: fadeSlideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
@apply text-gray-500 text-sm sm:text-base mb-4;
|
||||
animation: fadeSlideIn 0.5s ease-out 0.1s backwards;
|
||||
}
|
||||
|
||||
/* Touch feedback for list items */
|
||||
.list-item-touch {
|
||||
@apply relative overflow-hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.list-item-touch::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-gray-900/5 opacity-0 transition-opacity duration-150;
|
||||
}
|
||||
|
||||
.list-item-touch:active::before {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
/* Mobile card improvements */
|
||||
@media (max-width: 640px) {
|
||||
.card {
|
||||
@apply rounded-xl;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
@apply py-3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pull to refresh indicator */
|
||||
.pull-indicator {
|
||||
@apply flex items-center justify-center py-4 text-gray-400;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Swipeable item */
|
||||
.swipe-item {
|
||||
@apply relative;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
/* Bottom sheet handle */
|
||||
.sheet-handle {
|
||||
@apply w-10 h-1 bg-gray-300 rounded-full mx-auto mb-2;
|
||||
}
|
||||
|
||||
/* iOS-style segment control */
|
||||
.segment-control {
|
||||
@apply flex rounded-lg bg-gray-100 p-1 relative;
|
||||
}
|
||||
|
||||
.segment-item {
|
||||
@apply flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors relative z-10;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.segment-item-active {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
.segment-item-inactive {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
.segment-indicator {
|
||||
@apply absolute top-1 bottom-1 bg-white rounded-md shadow-sm transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-online {
|
||||
@apply w-2 h-2 bg-green-500 rounded-full;
|
||||
animation: pulseSoft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply w-2 h-2 bg-gray-400 rounded-full;
|
||||
}
|
||||
|
||||
/* Quick action buttons */
|
||||
.quick-action {
|
||||
@apply flex flex-col items-center justify-center p-3 rounded-xl;
|
||||
@apply bg-gray-50 hover:bg-gray-100 active:bg-gray-200;
|
||||
@apply transition-all duration-200;
|
||||
@apply min-w-[72px];
|
||||
}
|
||||
|
||||
.quick-action-icon {
|
||||
@apply w-10 h-10 rounded-full flex items-center justify-center mb-1;
|
||||
@apply transition-transform duration-200;
|
||||
}
|
||||
|
||||
.quick-action:active .quick-action-icon {
|
||||
@apply scale-90;
|
||||
}
|
||||
|
||||
.quick-action-label {
|
||||
@apply text-xs text-gray-600 font-medium;
|
||||
}
|
||||
|
||||
/* Badge animation */
|
||||
.badge-pulse {
|
||||
@apply relative;
|
||||
}
|
||||
.badge-pulse::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 rounded-full bg-current opacity-40;
|
||||
animation: badgePulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.skeleton {
|
||||
@apply bg-gray-200 rounded;
|
||||
animation: skeleton 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Tooltip animation */
|
||||
.tooltip {
|
||||
animation: tooltipIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Success checkmark animation */
|
||||
.success-check {
|
||||
animation: successCheck 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Shake animation for errors */
|
||||
.shake {
|
||||
animation: shake 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Slide transitions */
|
||||
.slide-enter {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-exit {
|
||||
animation: slideOut 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Rotate animation for refresh icons */
|
||||
.spin-once {
|
||||
animation: spinOnce 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Number counter animation */
|
||||
.counter {
|
||||
@apply tabular-nums;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
@apply h-1 bg-primary-600 rounded-full;
|
||||
animation: progressGrow 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Ripple effect */
|
||||
.ripple {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
.ripple::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-white/30 rounded-full scale-0;
|
||||
transition: transform 0.4s ease-out, opacity 0.4s ease-out;
|
||||
}
|
||||
.ripple:active::after {
|
||||
@apply scale-150 opacity-0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseSoft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes iconBounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes fabIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(-180deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes badgePulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes tooltipIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes successCheck {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-8px); }
|
||||
40% { transform: translateX(8px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
@keyframes spinOnce {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes progressGrow {
|
||||
from { width: 0; }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(-5deg); }
|
||||
75% { transform: rotate(5deg); }
|
||||
}
|
||||
|
||||
/* Leaflet map container */
|
||||
.leaflet-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Fix leaflet controls on mobile */
|
||||
.leaflet-control-zoom {
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
line-height: 36px !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for mobile */
|
||||
@media (max-width: 640px) {
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Smooth scroll on iOS */
|
||||
.smooth-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Pull to refresh indicator space */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
|
||||
/* Page transition wrapper */
|
||||
.page-transition {
|
||||
animation: pageIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Blob animation for login page */
|
||||
@keyframes blob {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
/* Shake animation for errors */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Bus, Lock, User, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(username, password);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 flex items-center justify-center p-4">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md relative">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center mb-8 animate-[fadeSlideIn_0.6s_ease-out]">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl shadow-lg mb-4 animate-[bounce_1s_ease-in-out]">
|
||||
<Bus className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Polotsk Transit</h1>
|
||||
<p className="text-gray-600">Административная панель</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-2xl p-8 border border-gray-100 animate-[scaleIn_0.4s_ease-out_0.2s_backwards]">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">Вход в систему</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 animate-[shake_0.4s_ease-out]">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-900">Ошибка входа</p>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="label">Имя пользователя</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="admin"
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="label">Пароль</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full btn-primary py-3 text-base font-semibold relative overflow-hidden group"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin inline" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relative z-10">Войти</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-700 to-primary-600 transform scale-x-0 group-hover:scale-x-100 transition-transform origin-left"></div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Мастер-аккаунт по умолчанию:
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded">admin / admin123</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-gray-500 mt-6 animate-[fadeIn_0.6s_ease-out_0.4s_backwards]">
|
||||
© 2024 Polotsk Transit API • v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
import axios from 'axios';
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const API_KEY = import.meta.env.VITE_API_KEY || 'dev_key_12345678';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Request interceptor for authentication
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
console.log(`API Request: ${config.method.toUpperCase()} ${config.url}`);
|
||||
|
||||
// Check if this is an auth endpoint
|
||||
const isAuthEndpoint = config.url?.startsWith('/auth/');
|
||||
const isPublicAuthEndpoint = config.url === '/auth/login' ||
|
||||
config.url === '/auth/refresh' ||
|
||||
config.url === '/auth/logout';
|
||||
|
||||
// For auth endpoints (except protected ones), don't add any auth headers
|
||||
if (isAuthEndpoint && isPublicAuthEndpoint) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// For all other endpoints, try to use JWT token first
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
|
||||
if (accessToken) {
|
||||
// Use JWT token if available
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
} else {
|
||||
// Fall back to API key for non-authenticated requests
|
||||
config.headers['X-API-Key'] = API_KEY;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
console.error('API Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling and token refresh
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 or 403 and we have a refresh token, try to refresh
|
||||
if ((error.response?.status === 401 || error.response?.status === 403) &&
|
||||
!originalRequest._retry &&
|
||||
localStorage.getItem('refreshToken')) {
|
||||
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken
|
||||
});
|
||||
|
||||
const { accessToken } = response.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, logout user
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
console.error('API Response Error:', error.response?.data || error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Routes API
|
||||
export const routesApi = {
|
||||
getAll: () => api.get('/routes'),
|
||||
getById: (id) => api.get(`/routes/${id}`),
|
||||
create: (data) => api.post('/routes', data),
|
||||
update: (id, data) => api.put(`/routes/${id}`, data),
|
||||
delete: (id) => api.delete(`/routes/${id}`)
|
||||
};
|
||||
|
||||
// Route Stops API (привязка остановок к маршрутам)
|
||||
export const routeStopsApi = {
|
||||
getRouteStops: (routeId) => api.get(`/routes/${routeId}/stops`),
|
||||
getAvailableStops: (routeId) => api.get(`/routes/${routeId}/available-stops`),
|
||||
addStop: (routeId, data) => api.post(`/routes/${routeId}/stops`, data),
|
||||
updateStop: (routeId, routeStopId, data) => api.put(`/routes/${routeId}/stops/${routeStopId}`, data),
|
||||
removeStop: (routeId, routeStopId) => api.delete(`/routes/${routeId}/stops/${routeStopId}`),
|
||||
reorderStops: (routeId, stops) => api.put(`/routes/${routeId}/stops-reorder`, { stops })
|
||||
};
|
||||
|
||||
// Stops API
|
||||
export const stopsApi = {
|
||||
getAll: () => api.get('/stops'),
|
||||
getById: (id) => api.get(`/stops/${id}`),
|
||||
getNearby: (lat, lon, radius = 500) =>
|
||||
api.get('/stops/nearby', { params: { lat, lon, radius } }),
|
||||
create: (data) => api.post('/stops', data),
|
||||
update: (id, data) => api.put(`/stops/${id}`, data),
|
||||
delete: (id) => api.delete(`/stops/${id}`)
|
||||
};
|
||||
|
||||
// ETA API
|
||||
export const etaApi = {
|
||||
calculate: (stopId, routeId) =>
|
||||
api.post('/eta/calculate', { stopId, routeId }),
|
||||
getStopEtas: (stopId) => api.get(`/eta/stop/${stopId}`)
|
||||
};
|
||||
|
||||
// Sync API
|
||||
export const syncApi = {
|
||||
sync: (lastSync) => api.get('/sync', { params: { lastSync } }),
|
||||
getStatus: () => api.get('/sync/status')
|
||||
};
|
||||
|
||||
// Schedules API (расписания)
|
||||
export const schedulesApi = {
|
||||
getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`),
|
||||
getTodaySchedule: (routeId, date) => api.get(`/routes/${routeId}/schedule/today`, { params: { date } }),
|
||||
getById: (id) => api.get(`/schedules/${id}`),
|
||||
create: (data) => api.post('/schedules', data),
|
||||
update: (id, data) => api.put(`/schedules/${id}`, data),
|
||||
delete: (id) => api.delete(`/schedules/${id}`),
|
||||
copy: (routeId, fromDayType, toDayType) => api.post(`/routes/${routeId}/schedules/copy`, { fromDayType, toDayType })
|
||||
};
|
||||
|
||||
// Holidays API (праздники)
|
||||
export const holidaysApi = {
|
||||
getAll: (year) => api.get('/holidays', { params: { year } }),
|
||||
getUpcoming: (limit) => api.get('/holidays/upcoming', { params: { limit } }),
|
||||
getForYear: (year) => api.get(`/holidays/year/${year}`),
|
||||
checkDate: (date) => api.get(`/holidays/check/${date}`),
|
||||
create: (data) => api.post('/holidays', data),
|
||||
update: (id, data) => api.put(`/holidays/${id}`, data),
|
||||
delete: (id) => api.delete(`/holidays/${id}`)
|
||||
};
|
||||
|
||||
// Alerts API (уведомления)
|
||||
export const alertsApi = {
|
||||
getAll: (params) => api.get('/alerts', { params }),
|
||||
getActive: (routeId) => api.get('/alerts/active', { params: { route_id: routeId } }),
|
||||
getById: (id) => api.get(`/alerts/${id}`),
|
||||
create: (data) => api.post('/alerts', data),
|
||||
update: (id, data) => api.put(`/alerts/${id}`, data),
|
||||
delete: (id) => api.delete(`/alerts/${id}`),
|
||||
cleanup: () => api.post('/alerts/cleanup')
|
||||
};
|
||||
|
||||
// Telemetry API (живые GPS-точки)
|
||||
export const telemetryApi = {
|
||||
ingest: (data) => api.post('/telemetry/ingest', data)
|
||||
};
|
||||
|
||||
// Realtime snapshot API (fallback/snapshot для live данных)
|
||||
export const realtimeApi = {
|
||||
getRouteVehicles: (routeId, params) => api.get(`/realtime/routes/${routeId}/vehicles`, { params }),
|
||||
getVehicle: (vehicleId, params) => api.get(`/realtime/vehicles/${vehicleId}`, { params })
|
||||
};
|
||||
|
||||
export default api;
|
||||
@ -0,0 +1,45 @@
|
||||
const DEFAULT_API_BASE_URL = 'http://localhost:3000/api/v1';
|
||||
|
||||
function normalizePath(pathname) {
|
||||
const trimmedPath = (pathname || '').replace(/\/+$/, '');
|
||||
|
||||
if (/\/api\/v\d+$/.test(trimmedPath)) {
|
||||
return trimmedPath;
|
||||
}
|
||||
|
||||
if (trimmedPath === '' || trimmedPath === '/') {
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
if (trimmedPath === '/api') {
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
return `${trimmedPath}/api/v1`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(rawValue = import.meta.env.VITE_API_URL || DEFAULT_API_BASE_URL) {
|
||||
try {
|
||||
const baseOrigin = typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: DEFAULT_API_BASE_URL;
|
||||
const url = new URL(rawValue || DEFAULT_API_BASE_URL, baseOrigin);
|
||||
|
||||
url.pathname = normalizePath(url.pathname);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch (error) {
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiOrigin(rawValue = import.meta.env.VITE_API_URL || DEFAULT_API_BASE_URL) {
|
||||
try {
|
||||
const url = new URL(getApiBaseUrl(rawValue));
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 3001
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,125 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=5r/s;
|
||||
|
||||
# Upstream backends
|
||||
upstream api_backend {
|
||||
server backend:3000;
|
||||
}
|
||||
|
||||
upstream admin_frontend {
|
||||
server frontend:80;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS (uncomment for production with SSL)
|
||||
# server {
|
||||
# listen 80;
|
||||
# server_name yourdomain.com;
|
||||
# return 301 https://$server_name$request_uri;
|
||||
# }
|
||||
|
||||
# API Server
|
||||
server {
|
||||
listen 80;
|
||||
# listen 443 ssl http2; # Uncomment for HTTPS
|
||||
server_name api.yourdomain.com localhost;
|
||||
|
||||
# SSL Configuration (uncomment and configure for production)
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/api_access.log;
|
||||
error_log /var/log/nginx/api_error.log;
|
||||
|
||||
# API endpoints
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
|
||||
proxy_pass http://api_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://api_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Health check (no rate limit)
|
||||
location /health {
|
||||
proxy_pass http://api_backend;
|
||||
proxy_http_version 1.1;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
|
||||
# Admin Panel Server
|
||||
server {
|
||||
listen 80;
|
||||
# listen 443 ssl http2; # Uncomment for HTTPS
|
||||
server_name admin.yourdomain.com localhost;
|
||||
|
||||
# SSL Configuration (same as above)
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/admin_access.log;
|
||||
error_log /var/log/nginx/admin_error.log;
|
||||
|
||||
location / {
|
||||
limit_req zone=admin_limit burst=10 nodelay;
|
||||
|
||||
proxy_pass http://admin_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# Default server (catch-all)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 444; # Close connection without response
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue