{city.name}
{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты можно указать при создании'}
diff --git a/backend/migrations/010_cities.sql b/backend/migrations/010_cities.sql
new file mode 100644
index 0000000..9d911e3
--- /dev/null
+++ b/backend/migrations/010_cities.sql
@@ -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 $$;
diff --git a/backend/src/controllers/citiesController.js b/backend/src/controllers/citiesController.js
new file mode 100644
index 0000000..e62d696
--- /dev/null
+++ b/backend/src/controllers/citiesController.js
@@ -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();
diff --git a/backend/src/controllers/etaController.js b/backend/src/controllers/etaController.js
index 51463fd..b515c1f 100644
--- a/backend/src/controllers/etaController.js
+++ b/backend/src/controllers/etaController.js
@@ -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)
diff --git a/backend/src/controllers/qualityController.js b/backend/src/controllers/qualityController.js
index 021c78f..cbb0b16 100644
--- a/backend/src/controllers/qualityController.js
+++ b/backend/src/controllers/qualityController.js
@@ -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: 'Не удалось проверить качество данных. Повторите попытку.' });
diff --git a/backend/src/controllers/routeStopsController.js b/backend/src/controllers/routeStopsController.js
index e82ee3e..445fc8c 100644
--- a/backend/src/controllers/routeStopsController.js
+++ b/backend/src/controllers/routeStopsController.js
@@ -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();
\ No newline at end of file
+module.exports = new RouteStopsController();
diff --git a/backend/src/controllers/routesController.js b/backend/src/controllers/routesController.js
index 822e3d3..01303dd 100644
--- a/backend/src/controllers/routesController.js
+++ b/backend/src/controllers/routesController.js
@@ -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' });
diff --git a/backend/src/controllers/schedulesController.js b/backend/src/controllers/schedulesController.js
index bd551ab..27cb0e1 100644
--- a/backend/src/controllers/schedulesController.js
+++ b/backend/src/controllers/schedulesController.js
@@ -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) {
diff --git a/backend/src/controllers/stopsController.js b/backend/src/controllers/stopsController.js
index accd29d..7ab1f5a 100644
--- a/backend/src/controllers/stopsController.js
+++ b/backend/src/controllers/stopsController.js
@@ -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' });
diff --git a/backend/src/controllers/syncController.js b/backend/src/controllers/syncController.js
index 812b631..a1e9e38 100644
--- a/backend/src/controllers/syncController.js
+++ b/backend/src/controllers/syncController.js
@@ -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',
diff --git a/backend/src/middleware/city.js b/backend/src/middleware/city.js
new file mode 100644
index 0000000..ab30e43
--- /dev/null
+++ b/backend/src/middleware/city.js
@@ -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 };
diff --git a/backend/src/routes/alerts.js b/backend/src/routes/alerts.js
index 5f10fad..70c78f5 100644
--- a/backend/src/routes/alerts.js
+++ b/backend/src/routes/alerts.js
@@ -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',
diff --git a/backend/src/routes/api.js b/backend/src/routes/api.js
index d9eee59..3399f5c 100644
--- a/backend/src/routes/api.js
+++ b/backend/src/routes/api.js
@@ -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));
diff --git a/backend/src/routes/cities.js b/backend/src/routes/cities.js
new file mode 100644
index 0000000..a2a7170
--- /dev/null
+++ b/backend/src/routes/cities.js
@@ -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;
diff --git a/backend/src/routes/scheduleImport.js b/backend/src/routes/scheduleImport.js
index 44bfa0d..91eb901 100644
--- a/backend/src/routes/scheduleImport.js
+++ b/backend/src/routes/scheduleImport.js
@@ -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 });
diff --git a/backend/src/services/alertCreationService.js b/backend/src/services/alertCreationService.js
index 558c3b8..d50e922 100644
--- a/backend/src/services/alertCreationService.js
+++ b/backend/src/services/alertCreationService.js
@@ -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];
diff --git a/backend/src/services/qualityReport.js b/backend/src/services/qualityReport.js
index 238828b..a390de7 100644
--- a/backend/src/services/qualityReport.js
+++ b/backend/src/services/qualityReport.js
@@ -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 };
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index ae61edb..381a80d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -3,6 +3,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import AppLayout from './components/AppLayout';
import { PageLoader } from './components/AnimatedComponents';
+import { CityProvider, useCity } from './contexts/CityContext';
// Pages
import LoginPage from './pages/LoginPage';
@@ -17,6 +18,7 @@ import HolidaysPage from './pages/HolidaysPage';
import AlertsPage from './pages/AlertsPage';
import UsersPage from './pages/UsersPage';
import ProfilePage from './pages/ProfilePage';
+import CitiesPage from './pages/CitiesPage';
function App() {
return (
@@ -42,19 +44,26 @@ function AppContent() {
);
}
- return (
- {error || 'В системе нет активных городов.'}Города недоступны
Города
+У каждого города — свои маршруты, остановки, расписания и уведомления.
{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты можно указать при создании'}
Переключение города в боковой панели меняет рабочий контур целиком: данные одного города не отображаются и не редактируются в другом.
Для города «{activeCity?.name}» официальный источник импорта пока не настроен.