feat: implement multi-city support with city management features
- Added a new cities table to the database to manage multiple cities independently. - Created a middleware to resolve city context based on X-City-Id header. - Updated routes and services to handle city-specific data for routes, stops, and alerts. - Implemented a CityContext in the frontend to manage active city state and provide city data across the application. - Added a CitySelector component for users to switch between cities. - Created a CitiesPage for admins to manage cities, including adding new cities with validation. - Updated various components and pages to reflect the active city context, ensuring data is displayed correctly based on the selected city. - Enhanced the schedule import functionality to restrict imports based on the active city.
This commit is contained in:
113
backend/migrations/010_cities.sql
Normal file
113
backend/migrations/010_cities.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
-- Cities are independent transport networks. Existing Polotsk data is moved to
|
||||
-- the default city so a second city can use the same route numbers and stop names.
|
||||
CREATE TABLE IF NOT EXISTS cities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL UNIQUE,
|
||||
name VARCHAR(120) NOT NULL UNIQUE,
|
||||
emblem VARCHAR(64) NOT NULL DEFAULT '🏙️',
|
||||
center_lat NUMERIC(9, 6),
|
||||
center_lon NUMERIC(9, 6),
|
||||
schedule_source_url TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT cities_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
|
||||
CONSTRAINT cities_center_pair CHECK (
|
||||
(center_lat IS NULL AND center_lon IS NULL)
|
||||
OR (center_lat BETWEEN -90 AND 90 AND center_lon BETWEEN -180 AND 180)
|
||||
)
|
||||
);
|
||||
|
||||
INSERT INTO cities (slug, name, emblem, center_lat, center_lon, schedule_source_url)
|
||||
VALUES
|
||||
('polotsk', 'Полоцк', '🏛️', 55.4869, 28.7856, 'https://ap2polotsk.of.by/ap2/'),
|
||||
('novopolotsk', 'Новополоцк', '🏙️', 55.5318, 28.5987, NULL)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
ALTER TABLE routes ADD COLUMN IF NOT EXISTS city_id INTEGER;
|
||||
ALTER TABLE stops ADD COLUMN IF NOT EXISTS city_id INTEGER;
|
||||
ALTER TABLE alerts ADD COLUMN IF NOT EXISTS city_id INTEGER;
|
||||
|
||||
UPDATE routes SET city_id = (SELECT id FROM cities WHERE slug = 'polotsk') WHERE city_id IS NULL;
|
||||
UPDATE stops SET city_id = (SELECT id FROM cities WHERE slug = 'polotsk') WHERE city_id IS NULL;
|
||||
UPDATE alerts SET city_id = (SELECT id FROM cities WHERE slug = 'polotsk') WHERE city_id IS NULL;
|
||||
|
||||
ALTER TABLE routes ALTER COLUMN city_id SET NOT NULL;
|
||||
ALTER TABLE stops ALTER COLUMN city_id SET NOT NULL;
|
||||
ALTER TABLE alerts ALTER COLUMN city_id SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'routes_city_id_fkey') THEN
|
||||
ALTER TABLE routes ADD CONSTRAINT routes_city_id_fkey
|
||||
FOREIGN KEY (city_id) REFERENCES cities(id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'stops_city_id_fkey') THEN
|
||||
ALTER TABLE stops ADD CONSTRAINT stops_city_id_fkey
|
||||
FOREIGN KEY (city_id) REFERENCES cities(id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'alerts_city_id_fkey') THEN
|
||||
ALTER TABLE alerts ADD CONSTRAINT alerts_city_id_fkey
|
||||
FOREIGN KEY (city_id) REFERENCES cities(id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_city ON routes(city_id, is_active, route_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_stops_city ON stops(city_id, is_active, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_city_active ON alerts(city_id, is_active, start_time, end_time);
|
||||
|
||||
-- Incremental mobile sync must not leak even delete identifiers between cities.
|
||||
ALTER TABLE sync_log ADD COLUMN IF NOT EXISTS city_id INTEGER REFERENCES cities(id) ON DELETE SET NULL;
|
||||
|
||||
UPDATE sync_log l SET city_id = r.city_id
|
||||
FROM routes r
|
||||
WHERE l.table_name = 'routes' AND l.record_id = r.id AND l.city_id IS NULL;
|
||||
UPDATE sync_log l SET city_id = s.city_id
|
||||
FROM stops s
|
||||
WHERE l.table_name = 'stops' AND l.record_id = s.id AND l.city_id IS NULL;
|
||||
UPDATE sync_log l SET city_id = r.city_id
|
||||
FROM route_stops rs JOIN routes r ON r.id = rs.route_id
|
||||
WHERE l.table_name = 'route_stops' AND l.record_id = rs.id AND l.city_id IS NULL;
|
||||
UPDATE sync_log l SET city_id = r.city_id
|
||||
FROM schedules s JOIN routes r ON r.id = s.route_id
|
||||
WHERE l.table_name = 'schedules' AND l.record_id = s.id AND l.city_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_log_city_timestamp ON sync_log(city_id, timestamp DESC);
|
||||
|
||||
CREATE OR REPLACE FUNCTION log_sync_changes()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
sync_city_id INTEGER;
|
||||
related_route_id INTEGER;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME IN ('routes', 'stops', 'alerts') THEN
|
||||
sync_city_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.city_id ELSE NEW.city_id END;
|
||||
ELSIF TG_TABLE_NAME IN ('route_stops', 'schedules') THEN
|
||||
related_route_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.route_id ELSE NEW.route_id END;
|
||||
SELECT city_id INTO sync_city_id FROM routes WHERE id = related_route_id;
|
||||
END IF;
|
||||
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO sync_log (table_name, record_id, action, city_id)
|
||||
VALUES (TG_TABLE_NAME, OLD.id, 'delete', sync_city_id);
|
||||
RETURN OLD;
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
INSERT INTO sync_log (table_name, record_id, action, city_id)
|
||||
VALUES (TG_TABLE_NAME, NEW.id, 'update', sync_city_id);
|
||||
RETURN NEW;
|
||||
ELSIF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO sync_log (table_name, record_id, action, city_id)
|
||||
VALUES (TG_TABLE_NAME, NEW.id, 'insert', sync_city_id);
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_cities_updated_at') THEN
|
||||
CREATE TRIGGER update_cities_updated_at BEFORE UPDATE ON cities
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
65
backend/src/controllers/citiesController.js
Normal file
65
backend/src/controllers/citiesController.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
function normalizeSlug(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function validateCity({ name, slug, emblem, center_lat, center_lon }) {
|
||||
if (!String(name || '').trim()) return 'Укажите название города.';
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalizeSlug(slug))) {
|
||||
return 'Код города должен состоять из латинских строчных букв, цифр и дефисов.';
|
||||
}
|
||||
if (emblem !== undefined && String(emblem).trim().length > 64) return 'Иконка или герб слишком длинные.';
|
||||
const hasLat = center_lat !== undefined && center_lat !== '';
|
||||
const hasLon = center_lon !== undefined && center_lon !== '';
|
||||
if (hasLat !== hasLon) return 'Укажите обе координаты центра города.';
|
||||
if (hasLat && (!Number.isFinite(Number(center_lat)) || !Number.isFinite(Number(center_lon))
|
||||
|| Number(center_lat) < -90 || Number(center_lat) > 90 || Number(center_lon) < -180 || Number(center_lon) > 180)) {
|
||||
return 'Укажите корректные координаты центра города.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class CitiesController {
|
||||
async getAllCities(req, res) {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, slug, name, emblem, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
FROM cities
|
||||
WHERE is_active = true
|
||||
ORDER BY name COLLATE "ru-RU-x-icu"
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching cities', { message: error.message });
|
||||
res.status(500).json({ error: 'Не удалось загрузить города.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createCity(req, res) {
|
||||
const { name, slug, emblem, center_lat, center_lon, schedule_source_url } = req.body;
|
||||
const validationError = validateCity({ name, slug, emblem, center_lat, center_lon });
|
||||
if (validationError) return res.status(400).json({ error: validationError });
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO cities (name, slug, emblem, center_lat, center_lon, schedule_source_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, slug, name, emblem, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
`, [
|
||||
String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(),
|
||||
center_lat === '' || center_lat === undefined ? null : Number(center_lat),
|
||||
center_lon === '' || center_lon === undefined ? null : Number(center_lon),
|
||||
String(schedule_source_url || '').trim() || null
|
||||
]);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
if (error.code === '23505') return res.status(409).json({ error: 'Город с таким названием или кодом уже есть.' });
|
||||
logger.error('Error creating city', { message: error.message });
|
||||
res.status(500).json({ error: 'Не удалось добавить город.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CitiesController();
|
||||
@@ -1,6 +1,7 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const { getLocalDateTime, getIsoWeekday } = require('../utils/etaTime');
|
||||
const { getRouteInCity, getStopInCity } = require('../middleware/city');
|
||||
|
||||
class EtaController {
|
||||
// Calculate ETA for a stop on a route
|
||||
@@ -17,6 +18,13 @@ class EtaController {
|
||||
}
|
||||
|
||||
try {
|
||||
const [route, stop] = await Promise.all([
|
||||
getRouteInCity(routeId, req.city.id),
|
||||
getStopInCity(stopId, req.city.id)
|
||||
]);
|
||||
if (!route || !stop) {
|
||||
return res.status(404).json({ error: 'Route or stop not found in the selected city' });
|
||||
}
|
||||
// Get schedule type for today (holiday-aware)
|
||||
const typeResult = await pool.query(
|
||||
'SELECT get_schedule_type($1::date) as schedule_type',
|
||||
@@ -138,13 +146,16 @@ class EtaController {
|
||||
const { stopId } = req.params;
|
||||
|
||||
try {
|
||||
if (!await getStopInCity(stopId, req.city.id)) {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
}
|
||||
// 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]);
|
||||
WHERE rs.stop_id = $1 AND r.city_id = $2 AND r.is_active = true
|
||||
`, [stopId, req.city.id]);
|
||||
|
||||
const etaPromises = routesResult.rows.map(route =>
|
||||
this.calculateEtaForRoute(stopId, route.id)
|
||||
|
||||
@@ -9,7 +9,7 @@ class QualityController {
|
||||
|
||||
async getReport(req, res) {
|
||||
try {
|
||||
res.json(await this.report(this.pool));
|
||||
res.json(await this.report(this.pool, req.city.id));
|
||||
} catch (error) {
|
||||
logger.error('Error building quality report', { message: error.message });
|
||||
res.status(500).json({ error: 'Не удалось проверить качество данных. Повторите попытку.' });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const pool = require('../config/database');
|
||||
const redisClient = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
const { getRouteInCity, getStopInCity } = require('../middleware/city');
|
||||
|
||||
class RouteStopsController {
|
||||
// Get all stops for a route (ordered by sequence)
|
||||
@@ -20,9 +21,10 @@ class RouteStopsController {
|
||||
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
|
||||
JOIN routes r ON r.id = rs.route_id
|
||||
WHERE rs.route_id = $1 AND r.city_id = $2 AND s.city_id = $2
|
||||
ORDER BY rs.sequence
|
||||
`, [routeId]);
|
||||
`, [routeId, req.city.id]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
@@ -44,20 +46,14 @@ class RouteStopsController {
|
||||
|
||||
try {
|
||||
// Check if route exists
|
||||
const routeCheck = await pool.query(
|
||||
'SELECT id FROM routes WHERE id = $1',
|
||||
[routeId]
|
||||
);
|
||||
if (routeCheck.rows.length === 0) {
|
||||
const route = await getRouteInCity(routeId, req.city.id);
|
||||
if (!route) {
|
||||
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) {
|
||||
const stop = await getStopInCity(stopId, req.city.id);
|
||||
if (!stop) {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
}
|
||||
|
||||
@@ -88,8 +84,8 @@ class RouteStopsController {
|
||||
`, [routeId, stopId, actualSequence, timeOffsetMinutes]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del('routes:all');
|
||||
await redisClient.del(`route:${req.city.id}:${routeId}`);
|
||||
await redisClient.del(`routes:all:${req.city.id}`);
|
||||
|
||||
logger.info(`Stop ${stopId} added to route ${routeId} at position ${actualSequence}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
@@ -105,6 +101,9 @@ class RouteStopsController {
|
||||
const { sequence, timeOffsetMinutes } = req.body;
|
||||
|
||||
try {
|
||||
if (!await getRouteInCity(routeId, req.city.id)) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
// Get current route_stop
|
||||
const current = await pool.query(
|
||||
'SELECT * FROM route_stops WHERE id = $1 AND route_id = $2',
|
||||
@@ -151,7 +150,7 @@ class RouteStopsController {
|
||||
`, [sequence, timeOffsetMinutes, routeStopId, routeId]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del(`route:${req.city.id}:${routeId}`);
|
||||
|
||||
logger.info(`Route stop ${routeStopId} updated`);
|
||||
res.json(result.rows[0]);
|
||||
@@ -166,6 +165,9 @@ class RouteStopsController {
|
||||
const { routeId, routeStopId } = req.params;
|
||||
|
||||
try {
|
||||
if (!await getRouteInCity(routeId, req.city.id)) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
// 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',
|
||||
@@ -192,8 +194,8 @@ class RouteStopsController {
|
||||
`, [routeId, removedSequence]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del('routes:all');
|
||||
await redisClient.del(`route:${req.city.id}:${routeId}`);
|
||||
await redisClient.del(`routes:all:${req.city.id}`);
|
||||
|
||||
logger.info(`Stop removed from route ${routeId}`);
|
||||
res.json({ message: 'Stop removed from route successfully' });
|
||||
@@ -218,6 +220,17 @@ class RouteStopsController {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
if (!await getRouteInCity(routeId, req.city.id, client)) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
const stopIds = [...new Set(stops.map(stop => Number(stop.stopId)))];
|
||||
const cityStops = await client.query(
|
||||
'SELECT id FROM stops WHERE city_id = $1 AND id = ANY($2::int[])',
|
||||
[req.city.id, stopIds]
|
||||
);
|
||||
if (cityStops.rows.length !== stopIds.length) {
|
||||
return res.status(400).json({ error: 'All stops must belong to the selected city' });
|
||||
}
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Delete all existing route_stops for this route
|
||||
@@ -237,8 +250,8 @@ class RouteStopsController {
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del('routes:all');
|
||||
await redisClient.del(`route:${req.city.id}:${routeId}`);
|
||||
await redisClient.del(`routes:all:${req.city.id}`);
|
||||
|
||||
logger.info(`Route ${routeId} stops reordered: ${stops.length} stops`);
|
||||
res.json({ message: 'Route stops reordered successfully', count: stops.length });
|
||||
@@ -264,12 +277,13 @@ class RouteStopsController {
|
||||
ST_Y(s.location::geometry) as latitude,
|
||||
ST_X(s.location::geometry) as longitude
|
||||
FROM stops s
|
||||
WHERE s.is_active = true
|
||||
JOIN routes r ON r.id = $1 AND r.city_id = $2
|
||||
WHERE s.is_active = true AND s.city_id = $2
|
||||
AND s.id NOT IN (
|
||||
SELECT stop_id FROM route_stops WHERE route_id = $1
|
||||
)
|
||||
ORDER BY s.name
|
||||
`, [routeId]);
|
||||
`, [routeId, req.city.id]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
@@ -279,4 +293,4 @@ class RouteStopsController {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RouteStopsController();
|
||||
module.exports = new RouteStopsController();
|
||||
|
||||
@@ -8,8 +8,10 @@ class RoutesController {
|
||||
// Get all routes
|
||||
async getAllRoutes(req, res) {
|
||||
try {
|
||||
const cityId = req.city.id;
|
||||
// Try cache first
|
||||
const cached = await redisClient.get('routes:all');
|
||||
const cacheKey = `routes:all:${cityId}`;
|
||||
const cached = await redisClient.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json(JSON.parse(cached));
|
||||
}
|
||||
@@ -20,15 +22,15 @@ class RoutesController {
|
||||
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
|
||||
WHERE r.is_active = true AND r.city_id = $1
|
||||
GROUP BY r.id
|
||||
ORDER BY r.route_number
|
||||
`);
|
||||
`, [cityId]);
|
||||
|
||||
const routes = result.rows;
|
||||
|
||||
// Cache the result
|
||||
await redisClient.setEx('routes:all', CACHE_TTL, JSON.stringify(routes));
|
||||
await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(routes));
|
||||
|
||||
res.json(routes);
|
||||
} catch (error) {
|
||||
@@ -40,9 +42,10 @@ class RoutesController {
|
||||
// Get route by ID with stops
|
||||
async getRouteById(req, res) {
|
||||
const { id } = req.params;
|
||||
const cityId = req.city.id;
|
||||
|
||||
try {
|
||||
const cacheKey = `route:${id}`;
|
||||
const cacheKey = `route:${cityId}:${id}`;
|
||||
const cached = await redisClient.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json(JSON.parse(cached));
|
||||
@@ -50,8 +53,8 @@ class RoutesController {
|
||||
|
||||
// Get route info
|
||||
const routeResult = await pool.query(
|
||||
'SELECT * FROM routes WHERE id = $1 AND is_active = true',
|
||||
[id]
|
||||
'SELECT * FROM routes WHERE id = $1 AND city_id = $2 AND is_active = true',
|
||||
[id, cityId]
|
||||
);
|
||||
|
||||
if (routeResult.rows.length === 0) {
|
||||
@@ -68,9 +71,9 @@ class RoutesController {
|
||||
rs.time_offset_minutes
|
||||
FROM stops s
|
||||
JOIN route_stops rs ON s.id = rs.stop_id
|
||||
WHERE rs.route_id = $1
|
||||
WHERE rs.route_id = $1 AND s.city_id = $2
|
||||
ORDER BY rs.sequence
|
||||
`, [id]);
|
||||
`, [id, cityId]);
|
||||
|
||||
const route = {
|
||||
...routeResult.rows[0],
|
||||
@@ -90,6 +93,7 @@ class RoutesController {
|
||||
// Create new route
|
||||
async createRoute(req, res) {
|
||||
const { route_number, name, type, color, description } = req.body;
|
||||
const cityId = req.city.id;
|
||||
|
||||
if (!route_number || !name || !type) {
|
||||
return res.status(400).json({
|
||||
@@ -100,13 +104,13 @@ class RoutesController {
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO routes (route_number, name, type, color, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO routes (route_number, name, type, color, description, city_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`, [route_number, name, type, color || '#0066CC', description]);
|
||||
`, [route_number, name, type, color || '#0066CC', description, cityId]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del('routes:all');
|
||||
await redisClient.del(`routes:all:${cityId}`);
|
||||
|
||||
logger.info(`Route created: ${route_number} - ${name}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
@@ -120,6 +124,7 @@ class RoutesController {
|
||||
async updateRoute(req, res) {
|
||||
const { id } = req.params;
|
||||
const { route_number, name, type, color, description, is_active } = req.body;
|
||||
const cityId = req.city.id;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
@@ -131,17 +136,17 @@ class RoutesController {
|
||||
color = COALESCE($4, color),
|
||||
description = COALESCE($5, description),
|
||||
is_active = COALESCE($6, is_active)
|
||||
WHERE id = $7
|
||||
WHERE id = $7 AND city_id = $8
|
||||
RETURNING *
|
||||
`, [route_number, name, type, color, description, is_active, id]);
|
||||
`, [route_number, name, type, color, description, is_active, id, cityId]);
|
||||
|
||||
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}`);
|
||||
await redisClient.del(`routes:all:${cityId}`);
|
||||
await redisClient.del(`route:${cityId}:${id}`);
|
||||
|
||||
logger.info(`Route updated: ${id}`);
|
||||
res.json(result.rows[0]);
|
||||
@@ -154,11 +159,12 @@ class RoutesController {
|
||||
// Delete route
|
||||
async deleteRoute(req, res) {
|
||||
const { id } = req.params;
|
||||
const cityId = req.city.id;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM routes WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
'DELETE FROM routes WHERE id = $1 AND city_id = $2 RETURNING *',
|
||||
[id, cityId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
@@ -166,8 +172,8 @@ class RoutesController {
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del('routes:all');
|
||||
await redisClient.del(`route:${id}`);
|
||||
await redisClient.del(`routes:all:${cityId}`);
|
||||
await redisClient.del(`route:${cityId}:${id}`);
|
||||
|
||||
logger.info(`Route deleted: ${id}`);
|
||||
res.json({ message: 'Route deleted successfully' });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const pool = require('../config/database');
|
||||
const redisClient = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
const { getRouteInCity } = require('../middleware/city');
|
||||
|
||||
const VALID_DAY_TYPES = ['weekday', 'saturday', 'sunday', 'holiday'];
|
||||
|
||||
@@ -17,7 +18,7 @@ class SchedulesController {
|
||||
r.name as route_name
|
||||
FROM schedules s
|
||||
JOIN routes r ON s.route_id = r.id
|
||||
WHERE s.route_id = $1
|
||||
WHERE s.route_id = $1 AND r.city_id = $2
|
||||
ORDER BY
|
||||
CASE s.day_type
|
||||
WHEN 'weekday' THEN 1
|
||||
@@ -25,7 +26,7 @@ class SchedulesController {
|
||||
WHEN 'sunday' THEN 3
|
||||
WHEN 'holiday' THEN 4
|
||||
END
|
||||
`, [routeId]);
|
||||
`, [routeId, req.city.id]);
|
||||
|
||||
res.json(result.rows.map(schedule => this.withScheduleTrips(schedule)));
|
||||
} catch (error) {
|
||||
@@ -46,8 +47,8 @@ class SchedulesController {
|
||||
r.name as route_name
|
||||
FROM schedules s
|
||||
JOIN routes r ON s.route_id = r.id
|
||||
WHERE s.id = $1
|
||||
`, [id]);
|
||||
WHERE s.id = $1 AND r.city_id = $2
|
||||
`, [id, req.city.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
@@ -88,6 +89,9 @@ class SchedulesController {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!await getRouteInCity(routeId, req.city.id)) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
// Check if schedule already exists for this route and day type
|
||||
const existing = await pool.query(`
|
||||
SELECT id FROM schedules
|
||||
@@ -117,7 +121,7 @@ class SchedulesController {
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del(`route:${req.city.id}:${routeId}`);
|
||||
|
||||
logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`);
|
||||
res.status(201).json(this.withScheduleTrips(result.rows[0]));
|
||||
@@ -134,7 +138,11 @@ class SchedulesController {
|
||||
|
||||
try {
|
||||
// Get current schedule to find route_id
|
||||
const current = await pool.query('SELECT route_id FROM schedules WHERE id = $1', [id]);
|
||||
const current = await pool.query(`
|
||||
SELECT s.route_id FROM schedules s
|
||||
JOIN routes r ON r.id = s.route_id
|
||||
WHERE s.id = $1 AND r.city_id = $2
|
||||
`, [id, req.city.id]);
|
||||
if (current.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
@@ -169,7 +177,7 @@ class SchedulesController {
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${current.rows[0].route_id}`);
|
||||
await redisClient.del(`route:${req.city.id}:${current.rows[0].route_id}`);
|
||||
|
||||
logger.info(`Schedule ${id} updated`);
|
||||
res.json(this.withScheduleTrips(result.rows[0]));
|
||||
@@ -185,8 +193,10 @@ class SchedulesController {
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM schedules WHERE id = $1 RETURNING route_id',
|
||||
[id]
|
||||
`DELETE FROM schedules s USING routes r
|
||||
WHERE s.id = $1 AND r.id = s.route_id AND r.city_id = $2
|
||||
RETURNING s.route_id`,
|
||||
[id, req.city.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
@@ -194,7 +204,7 @@ class SchedulesController {
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${result.rows[0].route_id}`);
|
||||
await redisClient.del(`route:${req.city.id}:${result.rows[0].route_id}`);
|
||||
|
||||
logger.info(`Schedule ${id} deleted`);
|
||||
res.json({ message: 'Schedule deleted successfully' });
|
||||
@@ -226,12 +236,12 @@ class SchedulesController {
|
||||
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
|
||||
WHERE s.route_id = $1 AND r.city_id = $4
|
||||
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]);
|
||||
`, [routeId, scheduleType, targetDate, req.city.id]);
|
||||
|
||||
// If holiday and no specific holiday schedule, fall back to sunday
|
||||
if (result.rows.length === 0 && scheduleType === 'holiday') {
|
||||
@@ -239,12 +249,12 @@ class SchedulesController {
|
||||
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
|
||||
WHERE s.route_id = $1 AND r.city_id = $3
|
||||
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]);
|
||||
`, [routeId, targetDate, req.city.id]);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
@@ -287,6 +297,9 @@ class SchedulesController {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!await getRouteInCity(routeId, req.city.id)) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
// Get source schedule
|
||||
const source = await pool.query(`
|
||||
SELECT departure_times, trips, valid_from, valid_until FROM schedules
|
||||
@@ -352,7 +365,7 @@ class SchedulesController {
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del(`route:${req.city.id}:${routeId}`);
|
||||
|
||||
logger.info(`Schedule copied from ${fromDayType} to ${toDayType} for route ${routeId}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,7 +8,9 @@ class StopsController {
|
||||
// Get all stops
|
||||
async getAllStops(req, res) {
|
||||
try {
|
||||
const cached = await redisClient.get('stops:all');
|
||||
const cityId = req.city.id;
|
||||
const cacheKey = `stops:all:${cityId}`;
|
||||
const cached = await redisClient.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json(JSON.parse(cached));
|
||||
}
|
||||
@@ -21,13 +23,13 @@ class StopsController {
|
||||
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
|
||||
WHERE s.is_active = true AND s.city_id = $1
|
||||
GROUP BY s.id
|
||||
ORDER BY s.name
|
||||
`);
|
||||
`, [cityId]);
|
||||
|
||||
const stops = result.rows;
|
||||
await redisClient.setEx('stops:all', CACHE_TTL, JSON.stringify(stops));
|
||||
await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(stops));
|
||||
|
||||
res.json(stops);
|
||||
} catch (error) {
|
||||
@@ -39,6 +41,7 @@ class StopsController {
|
||||
// Get stop by ID
|
||||
async getStopById(req, res) {
|
||||
const { id } = req.params;
|
||||
const cityId = req.city.id;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
@@ -47,8 +50,8 @@ class StopsController {
|
||||
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]);
|
||||
WHERE s.id = $1 AND s.city_id = $2 AND s.is_active = true
|
||||
`, [id, cityId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
@@ -62,9 +65,9 @@ class StopsController {
|
||||
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
|
||||
WHERE rs.stop_id = $1 AND r.city_id = $2 AND r.is_active = true
|
||||
ORDER BY r.route_number
|
||||
`, [id]);
|
||||
`, [id, cityId]);
|
||||
|
||||
const stop = {
|
||||
...result.rows[0],
|
||||
@@ -81,6 +84,7 @@ class StopsController {
|
||||
// Get nearby stops (PostGIS query)
|
||||
async getNearbyStops(req, res) {
|
||||
const { lat, lon, radius = 500 } = req.query;
|
||||
const cityId = req.city.id;
|
||||
|
||||
if (!lat || !lon) {
|
||||
return res.status(400).json({
|
||||
@@ -100,7 +104,7 @@ class StopsController {
|
||||
ST_MakePoint($2, $1)::geography
|
||||
) as distance_meters
|
||||
FROM stops s
|
||||
WHERE s.is_active = true
|
||||
WHERE s.is_active = true AND s.city_id = $4
|
||||
AND ST_DWithin(
|
||||
s.location,
|
||||
ST_MakePoint($2, $1)::geography,
|
||||
@@ -108,7 +112,7 @@ class StopsController {
|
||||
)
|
||||
ORDER BY distance_meters
|
||||
LIMIT 20
|
||||
`, [parseFloat(lat), parseFloat(lon), parseFloat(radius)]);
|
||||
`, [parseFloat(lat), parseFloat(lon), parseFloat(radius), cityId]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
@@ -120,6 +124,7 @@ class StopsController {
|
||||
// Create stop
|
||||
async createStop(req, res) {
|
||||
const { name, latitude, longitude, address, description } = req.body;
|
||||
const cityId = req.city.id;
|
||||
|
||||
if (!name || !latitude || !longitude) {
|
||||
return res.status(400).json({
|
||||
@@ -130,20 +135,21 @@ class StopsController {
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO stops (name, location, address, description)
|
||||
INSERT INTO stops (name, location, address, description, city_id)
|
||||
VALUES (
|
||||
$1,
|
||||
ST_SetSRID(ST_MakePoint($3, $2), 4326)::geography,
|
||||
$4,
|
||||
$5
|
||||
$5,
|
||||
$6
|
||||
)
|
||||
RETURNING
|
||||
*,
|
||||
ST_Y(location::geometry) as latitude,
|
||||
ST_X(location::geometry) as longitude
|
||||
`, [name, latitude, longitude, address, description]);
|
||||
`, [name, latitude, longitude, address, description, cityId]);
|
||||
|
||||
await redisClient.del('stops:all');
|
||||
await redisClient.del(`stops:all:${cityId}`);
|
||||
|
||||
logger.info(`Stop created: ${name}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
@@ -157,6 +163,7 @@ class StopsController {
|
||||
async updateStop(req, res) {
|
||||
const { id } = req.params;
|
||||
const { name, latitude, longitude, address, description, is_active } = req.body;
|
||||
const cityId = req.city.id;
|
||||
|
||||
try {
|
||||
let query = 'UPDATE stops SET ';
|
||||
@@ -190,8 +197,8 @@ class StopsController {
|
||||
}
|
||||
|
||||
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);
|
||||
query += ` WHERE id = $${paramCount} AND city_id = $${paramCount + 1} RETURNING *, ST_Y(location::geometry) as latitude, ST_X(location::geometry) as longitude`;
|
||||
values.push(id, cityId);
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
@@ -199,7 +206,7 @@ class StopsController {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
}
|
||||
|
||||
await redisClient.del('stops:all');
|
||||
await redisClient.del(`stops:all:${cityId}`);
|
||||
|
||||
logger.info(`Stop updated: ${id}`);
|
||||
res.json(result.rows[0]);
|
||||
@@ -212,18 +219,19 @@ class StopsController {
|
||||
// Delete stop
|
||||
async deleteStop(req, res) {
|
||||
const { id } = req.params;
|
||||
const cityId = req.city.id;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM stops WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
'DELETE FROM stops WHERE id = $1 AND city_id = $2 RETURNING *',
|
||||
[id, cityId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
}
|
||||
|
||||
await redisClient.del('stops:all');
|
||||
await redisClient.del(`stops:all:${cityId}`);
|
||||
|
||||
logger.info(`Stop deleted: ${id}`);
|
||||
res.json({ message: 'Stop deleted successfully' });
|
||||
|
||||
@@ -23,10 +23,11 @@ class SyncController {
|
||||
// Full synchronization (all data)
|
||||
async fullSync(req, res) {
|
||||
try {
|
||||
const cityId = req.city.id;
|
||||
// Get all active routes
|
||||
const routesResult = await pool.query(`
|
||||
SELECT * FROM routes WHERE is_active = true
|
||||
`);
|
||||
SELECT * FROM routes WHERE is_active = true AND city_id = $1
|
||||
`, [cityId]);
|
||||
|
||||
// Get all active stops with coordinates
|
||||
const stopsResult = await pool.query(`
|
||||
@@ -35,8 +36,8 @@ class SyncController {
|
||||
ST_Y(location::geometry) as latitude,
|
||||
ST_X(location::geometry) as longitude
|
||||
FROM stops
|
||||
WHERE is_active = true
|
||||
`);
|
||||
WHERE is_active = true AND city_id = $1
|
||||
`, [cityId]);
|
||||
|
||||
// Get route-stop relationships
|
||||
const routeStopsResult = await pool.query(`
|
||||
@@ -45,25 +46,26 @@ class SyncController {
|
||||
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
|
||||
`);
|
||||
AND r.city_id = $1 AND s.city_id = $1
|
||||
`, [cityId]);
|
||||
|
||||
// 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
|
||||
WHERE r.is_active = true AND r.city_id = $1
|
||||
AND (s.valid_from IS NULL OR s.valid_from <= CURRENT_DATE)
|
||||
AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE)
|
||||
`);
|
||||
`, [cityId]);
|
||||
|
||||
// Get active alerts
|
||||
const alertsResult = await pool.query(`
|
||||
SELECT * FROM alerts
|
||||
WHERE is_active = true
|
||||
WHERE city_id = $1 AND is_active = true
|
||||
AND start_time <= CURRENT_TIMESTAMP
|
||||
AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP)
|
||||
`);
|
||||
`, [cityId]);
|
||||
|
||||
// Get holidays for current and next year
|
||||
const holidaysResult = await pool.query(`
|
||||
@@ -125,9 +127,9 @@ class SyncController {
|
||||
action,
|
||||
timestamp
|
||||
FROM sync_log
|
||||
WHERE timestamp > $1
|
||||
WHERE timestamp > $1 AND city_id = $2
|
||||
ORDER BY table_name, record_id, timestamp DESC
|
||||
`, [lastSyncDate]);
|
||||
`, [lastSyncDate, req.city.id]);
|
||||
|
||||
const changes = {
|
||||
routes: { updated: [], deleted: [] },
|
||||
@@ -146,7 +148,7 @@ class SyncController {
|
||||
}
|
||||
} else {
|
||||
// Get updated record
|
||||
const record = await this.getRecord(table_name, record_id);
|
||||
const record = await this.getRecord(table_name, record_id, req.city.id);
|
||||
if (record && changes[table_name]) {
|
||||
changes[table_name].updated.push(record);
|
||||
}
|
||||
@@ -156,9 +158,9 @@ class SyncController {
|
||||
// Get new alerts
|
||||
const alertsResult = await pool.query(`
|
||||
SELECT * FROM alerts
|
||||
WHERE is_active = true
|
||||
WHERE city_id = $2 AND is_active = true
|
||||
AND created_at > $1
|
||||
`, [lastSyncDate]);
|
||||
`, [lastSyncDate, req.city.id]);
|
||||
|
||||
const syncData = {
|
||||
syncType: 'incremental',
|
||||
@@ -181,13 +183,13 @@ class SyncController {
|
||||
}
|
||||
|
||||
// Helper: Get record from table
|
||||
async getRecord(tableName, recordId) {
|
||||
async getRecord(tableName, recordId, cityId) {
|
||||
try {
|
||||
let query = '';
|
||||
|
||||
switch (tableName) {
|
||||
case 'routes':
|
||||
query = 'SELECT * FROM routes WHERE id = $1 AND is_active = true';
|
||||
query = 'SELECT * FROM routes WHERE id = $1 AND city_id = $2 AND is_active = true';
|
||||
break;
|
||||
case 'stops':
|
||||
query = `
|
||||
@@ -196,16 +198,19 @@ class SyncController {
|
||||
ST_Y(location::geometry) as latitude,
|
||||
ST_X(location::geometry) as longitude
|
||||
FROM stops
|
||||
WHERE id = $1 AND is_active = true
|
||||
WHERE id = $1 AND city_id = $2 AND is_active = true
|
||||
`;
|
||||
break;
|
||||
case 'route_stops':
|
||||
query = 'SELECT * FROM route_stops WHERE id = $1';
|
||||
query = `SELECT rs.* FROM route_stops rs
|
||||
JOIN routes r ON r.id = rs.route_id
|
||||
WHERE rs.id = $1 AND r.city_id = $2`;
|
||||
break;
|
||||
case 'schedules':
|
||||
query = `
|
||||
SELECT * FROM schedules
|
||||
WHERE id = $1
|
||||
SELECT schedules.* FROM schedules
|
||||
JOIN routes r ON r.id = schedules.route_id
|
||||
WHERE schedules.id = $1 AND r.city_id = $2
|
||||
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||
`;
|
||||
@@ -214,7 +219,7 @@ class SyncController {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await pool.query(query, [recordId]);
|
||||
const result = await pool.query(query, [recordId, cityId]);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
|
||||
} catch (error) {
|
||||
@@ -226,13 +231,14 @@ class SyncController {
|
||||
// Get sync status
|
||||
async getSyncStatus(req, res) {
|
||||
try {
|
||||
const cityId = req.city.id;
|
||||
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
|
||||
`);
|
||||
(SELECT COUNT(*) FROM routes WHERE is_active = true AND city_id = $1) as routes_count,
|
||||
(SELECT COUNT(*) FROM stops WHERE is_active = true AND city_id = $1) as stops_count,
|
||||
(SELECT COUNT(*) FROM schedules s JOIN routes r ON r.id = s.route_id WHERE r.city_id = $1) as schedules_count,
|
||||
(SELECT MAX(timestamp) FROM sync_log WHERE city_id = $1) as last_change
|
||||
`, [cityId]);
|
||||
|
||||
res.json({
|
||||
status: 'ready',
|
||||
|
||||
46
backend/src/middleware/city.js
Normal file
46
backend/src/middleware/city.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const DEFAULT_CITY_SLUG = 'polotsk';
|
||||
|
||||
async function resolveCity(req, res, next) {
|
||||
const rawCityId = req.get('X-City-Id');
|
||||
|
||||
if (rawCityId !== undefined && (!/^\d+$/.test(rawCityId) || Number(rawCityId) <= 0)) {
|
||||
return res.status(400).json({ error: 'Invalid city identifier' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = rawCityId
|
||||
? await pool.query('SELECT * FROM cities WHERE id = $1 AND is_active = true', [Number(rawCityId)])
|
||||
: await pool.query('SELECT * FROM cities WHERE slug = $1 AND is_active = true', [DEFAULT_CITY_SLUG]);
|
||||
|
||||
if (!result.rows.length) {
|
||||
return res.status(404).json({ error: 'City not found or inactive' });
|
||||
}
|
||||
|
||||
req.city = result.rows[0];
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('Unable to resolve city context', { message: error.message });
|
||||
return res.status(500).json({ error: 'Unable to resolve city' });
|
||||
}
|
||||
}
|
||||
|
||||
async function getRouteInCity(routeId, cityId, db = pool) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM routes WHERE id = $1 AND city_id = $2',
|
||||
[routeId, cityId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function getStopInCity(stopId, cityId, db = pool) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM stops WHERE id = $1 AND city_id = $2',
|
||||
[stopId, cityId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_CITY_SLUG, resolveCity, getRouteInCity, getStopInCity };
|
||||
@@ -4,6 +4,7 @@ const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const { getAlertCreationService } = require('../services/alertCreationService');
|
||||
const { listAlertTemplates } = require('../services/alertTemplates');
|
||||
const { getRouteInCity } = require('../middleware/city');
|
||||
|
||||
function validateAlertTimes({ start_time, end_time }) {
|
||||
const start = new Date(start_time);
|
||||
@@ -24,10 +25,10 @@ router.get('/', async (req, res) => {
|
||||
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
|
||||
WHERE a.city_id = $1
|
||||
`;
|
||||
const params = [];
|
||||
let paramCount = 1;
|
||||
const params = [req.city.id];
|
||||
let paramCount = 2;
|
||||
|
||||
if (route_id) {
|
||||
query += ` AND a.route_id = $${paramCount}`;
|
||||
@@ -66,14 +67,15 @@ router.get('/active', async (req, res) => {
|
||||
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
|
||||
WHERE a.city_id = $1
|
||||
AND a.is_active = true
|
||||
AND a.start_time <= NOW()
|
||||
AND (a.end_time IS NULL OR a.end_time >= NOW())
|
||||
`;
|
||||
const params = [];
|
||||
const params = [req.city.id];
|
||||
|
||||
if (route_id) {
|
||||
query += ' AND a.route_id = $1';
|
||||
query += ' AND a.route_id = $2';
|
||||
params.push(route_id);
|
||||
}
|
||||
|
||||
@@ -99,8 +101,8 @@ router.get('/:id', async (req, res) => {
|
||||
`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]
|
||||
WHERE a.id = $1 AND a.city_id = $2`,
|
||||
[id, req.city.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
@@ -132,9 +134,13 @@ function createAlertHandler({ alertCreationService = null } = {}) {
|
||||
return res.status(400).json({ error: 'Invalid alert_type' });
|
||||
}
|
||||
|
||||
if (route_id && !await getRouteInCity(route_id, req.city.id)) {
|
||||
return res.status(400).json({ error: 'Маршрут не принадлежит выбранному городу.' });
|
||||
}
|
||||
|
||||
const service = alertCreationService || getAlertCreationService();
|
||||
const result = await service.createAlert(
|
||||
{ route_id, alert_type, title, message, start_time, end_time, is_active },
|
||||
{ route_id, alert_type, title, message, start_time, end_time, is_active, city_id: req.city.id },
|
||||
{ idempotencyKey: req.get('Idempotency-Key') }
|
||||
);
|
||||
|
||||
@@ -163,7 +169,10 @@ router.put('/:id', async (req, res) => {
|
||||
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 * FROM alerts WHERE id = $1', [id]);
|
||||
const checkResult = await pool.query(
|
||||
'SELECT * FROM alerts WHERE id = $1 AND city_id = $2',
|
||||
[id, req.city.id]
|
||||
);
|
||||
if (checkResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Alert not found' });
|
||||
}
|
||||
@@ -182,6 +191,10 @@ router.put('/:id', async (req, res) => {
|
||||
});
|
||||
if (timeError) return res.status(400).json({ error: timeError });
|
||||
|
||||
if (route_id && !await getRouteInCity(route_id, req.city.id)) {
|
||||
return res.status(400).json({ error: 'Маршрут не принадлежит выбранному городу.' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE alerts
|
||||
SET route_id = COALESCE($1, route_id),
|
||||
@@ -191,9 +204,9 @@ router.put('/:id', async (req, res) => {
|
||||
start_time = COALESCE($5, start_time),
|
||||
end_time = $6,
|
||||
is_active = COALESCE($7, is_active)
|
||||
WHERE id = $8
|
||||
WHERE id = $8 AND city_id = $9
|
||||
RETURNING *`,
|
||||
[route_id, alert_type, title, message, start_time, end_time, is_active, id]
|
||||
[route_id, alert_type, title, message, start_time, end_time, is_active, id, req.city.id]
|
||||
);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
@@ -207,7 +220,7 @@ router.put('/:id', async (req, res) => {
|
||||
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]);
|
||||
const result = await pool.query('DELETE FROM alerts WHERE id = $1 AND city_id = $2 RETURNING id', [id, req.city.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Alert not found' });
|
||||
@@ -226,11 +239,11 @@ router.post('/cleanup', async (req, res) => {
|
||||
const result = await pool.query(
|
||||
`UPDATE alerts
|
||||
SET is_active = false
|
||||
WHERE is_active = true
|
||||
WHERE city_id = $1 AND is_active = true
|
||||
AND end_time IS NOT NULL
|
||||
AND end_time < NOW()
|
||||
RETURNING id`
|
||||
);
|
||||
, [req.city.id]);
|
||||
|
||||
res.json({
|
||||
message: 'Cleanup completed',
|
||||
|
||||
@@ -13,8 +13,14 @@ const telemetryController = require('../controllers/telemetryController');
|
||||
const passengerTelemetryController = require('../controllers/passengerTelemetryController');
|
||||
const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { resolveCity } = require('../middleware/city');
|
||||
const qualityController = require('../controllers/qualityController');
|
||||
|
||||
// City catalogue is global. Every transport-data endpoint below receives a
|
||||
// verified city context from X-City-Id (or falls back to the legacy Polotsk city).
|
||||
router.use('/cities', require('./cities'));
|
||||
router.use(resolveCity);
|
||||
|
||||
router.use('/schedule-import', require('./scheduleImport'));
|
||||
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController));
|
||||
|
||||
|
||||
10
backend/src/routes/cities.js
Normal file
10
backend/src/routes/cities.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const citiesController = require('../controllers/citiesController');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', citiesController.getAllCities.bind(citiesController));
|
||||
router.post('/', authenticateToken, requireAdmin, citiesController.createCity.bind(citiesController));
|
||||
|
||||
module.exports = router;
|
||||
@@ -7,10 +7,20 @@ const { ScheduleImportService } = require('../services/scheduleImportService');
|
||||
|
||||
const service = new ScheduleImportService({ pool, invalidate: async routeId => {
|
||||
if (!redis.isReady) throw new Error('Redis unavailable');
|
||||
await redis.del([`route:${routeId}`, 'routes:all']);
|
||||
const route = (await pool.query('SELECT city_id FROM routes WHERE id = $1', [routeId])).rows[0];
|
||||
if (!route) return;
|
||||
await redis.del([`route:${route.city_id}:${routeId}`, `routes:all:${route.city_id}`]);
|
||||
} });
|
||||
const router = express.Router();
|
||||
router.use(authenticateToken, requireAdmin);
|
||||
router.use((req, res, next) => {
|
||||
if (req.city?.slug !== 'polotsk') {
|
||||
return res.status(409).json({
|
||||
error: 'Для выбранного города не настроен официальный источник импорта. Добавляйте маршруты, остановки и расписания вручную.'
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
const handle = action => async (req, res) => {
|
||||
try { res.json(await action(req)); } catch (error) {
|
||||
logger.error('Schedule import failed', { message: error.message });
|
||||
|
||||
@@ -2,8 +2,8 @@ const crypto = require('node:crypto');
|
||||
|
||||
const INSERT_ALERT_SQL = `
|
||||
WITH inserted AS (
|
||||
INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active, city_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT inserted.*, routes.route_number AS notification_route_number
|
||||
@@ -43,7 +43,8 @@ function normalizeAlertInput(payload) {
|
||||
message: payload.message,
|
||||
start_time: payload.start_time,
|
||||
end_time: payload.end_time || null,
|
||||
is_active: payload.is_active !== false
|
||||
is_active: payload.is_active !== false,
|
||||
city_id: payload.city_id || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +64,8 @@ function createAlertRequestHash(alert) {
|
||||
message: normalizeHashValue(alert.message),
|
||||
start_time: normalizeHashValue(alert.start_time),
|
||||
end_time: normalizeHashValue(alert.end_time),
|
||||
is_active: normalizeHashValue(alert.is_active)
|
||||
is_active: normalizeHashValue(alert.is_active),
|
||||
city_id: normalizeHashValue(alert.city_id)
|
||||
};
|
||||
|
||||
return crypto
|
||||
@@ -146,7 +148,8 @@ class AlertCreationService {
|
||||
alertInput.message,
|
||||
alertInput.start_time,
|
||||
alertInput.end_time,
|
||||
alertInput.is_active
|
||||
alertInput.is_active,
|
||||
alertInput.city_id
|
||||
]);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
@@ -277,9 +277,23 @@ function buildQualityReport(rowsByCheck, checkedAt = new Date().toISOString()) {
|
||||
return { checked_at: checkedAt, summary, issues };
|
||||
}
|
||||
|
||||
async function runQualityChecks(pool) {
|
||||
const results = await Promise.all(qualityChecks.map(async ({ name, query }) => [name, (await pool.query(query)).rows]));
|
||||
function withCityScope(query, cityId) {
|
||||
if (!cityId) return { query, params: [] };
|
||||
return {
|
||||
query: query
|
||||
.replaceAll('r.is_active = true', 'r.is_active = true AND r.city_id = $1')
|
||||
.replace('WHERE s.is_active = true', 'WHERE s.is_active = true AND s.city_id = $1')
|
||||
.replace('WHERE a.is_active = true', 'WHERE a.is_active = true AND a.city_id = $1'),
|
||||
params: [cityId]
|
||||
};
|
||||
}
|
||||
|
||||
async function runQualityChecks(pool, cityId = null) {
|
||||
const results = await Promise.all(qualityChecks.map(async ({ name, query }) => {
|
||||
const scoped = withCityScope(query, cityId);
|
||||
return [name, (await pool.query(scoped.query, scoped.params)).rows];
|
||||
}));
|
||||
return buildQualityReport(Object.fromEntries(results));
|
||||
}
|
||||
|
||||
module.exports = { buildQualityReport, qualityChecks, runQualityChecks };
|
||||
module.exports = { buildQualityReport, qualityChecks, runQualityChecks, withCityScope };
|
||||
|
||||
Reference in New Issue
Block a user