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:
2026-09-17 19:41:17 +03:00
parent d284c6e940
commit d13cc92d82
27 changed files with 730 additions and 144 deletions

View 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 $$;

View 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();

View File

@@ -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)

View File

@@ -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: 'Не удалось проверить качество данных. Повторите попытку.' });

View File

@@ -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();

View File

@@ -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' });

View File

@@ -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) {

View File

@@ -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' });

View File

@@ -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',

View 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 };

View File

@@ -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',

View File

@@ -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));

View 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;

View File

@@ -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 });

View File

@@ -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];

View File

@@ -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 };