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 ( - - - - ); + return ; } -function AnimatedRoutes() { +function CityWorkspace() { + const { loading, error, activeCity } = useCity(); + + if (loading) return ; + if (error || !activeCity) return

Города недоступны

{error || 'В системе нет активных городов.'}

; + + return + + ; +} + +function AnimatedRoutes({ cityId }) { const location = useLocation(); const { isAdmin } = useAuth(); return ( -
+
} /> } /> @@ -66,6 +75,7 @@ function AnimatedRoutes() { } /> } /> } /> + {isAdmin && } />} {isAdmin && ( } /> )} diff --git a/frontend/src/components/AppLayout.jsx b/frontend/src/components/AppLayout.jsx index 44da8ef..d541cf5 100644 --- a/frontend/src/components/AppLayout.jsx +++ b/frontend/src/components/AppLayout.jsx @@ -4,6 +4,8 @@ import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, import { useAuth } from '../contexts/AuthContext'; import BrandLogo from './BrandLogo'; import ProfileMenu from './ProfileMenu'; +import CitySelector from './CitySelector'; +import { useCity } from '../contexts/CityContext'; const navigationGroups = [ { label: 'Обзор', items: [{ to: '/', label: 'Главная', icon: LayoutDashboard }] }, @@ -24,6 +26,7 @@ const navigationGroups = [ export default function AppLayout({ children }) { const { isAdmin } = useAuth(); + const { activeCity } = useCity(); const { pathname } = useLocation(); const [menuOpen, setMenuOpen] = useState(false); const menuButton = useRef(null); @@ -55,6 +58,7 @@ export default function AppLayout({ children }) { diff --git a/frontend/src/components/CitySelector.jsx b/frontend/src/components/CitySelector.jsx new file mode 100644 index 0000000..633e8f5 --- /dev/null +++ b/frontend/src/components/CitySelector.jsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { Check, ChevronDown, MapPinned, Plus } from 'lucide-react'; +import { Link, useLocation } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { useCity } from '../contexts/CityContext'; + +function CityEmblem({ city, className = '' }) { + return ; +} + +export default function CitySelector() { + const { cities, activeCity, selectCity } = useCity(); + const { isAdmin } = useAuth(); + const { pathname } = useLocation(); + const [open, setOpen] = useState(false); + + if (!activeCity) return null; + + return
+ + {open &&
+

Города

+
+ {cities.map(city => )} +
+ {isAdmin && <> +
+ setOpen(false)}> +
} +
; +} + +export { CityEmblem }; diff --git a/frontend/src/contexts/CityContext.jsx b/frontend/src/contexts/CityContext.jsx new file mode 100644 index 0000000..1cacdc0 --- /dev/null +++ b/frontend/src/contexts/CityContext.jsx @@ -0,0 +1,53 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { citiesApi } from '../services/api'; + +const CityContext = createContext(null); +const STORAGE_KEY = 'activeCityId'; + +export function CityProvider({ children }) { + const [cities, setCities] = useState([]); + const [activeCity, setActiveCity] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + async function loadCities() { + setError(''); + try { + const { data } = await citiesApi.getAll(); + const savedId = Number(localStorage.getItem(STORAGE_KEY)); + const selected = data.find(city => city.id === savedId) + || data.find(city => city.slug === 'polotsk') + || data[0] + || null; + setCities(data); + setActiveCity(selected); + if (selected) localStorage.setItem(STORAGE_KEY, String(selected.id)); + } catch (requestError) { + setError(requestError.response?.data?.error || 'Не удалось загрузить список городов.'); + } finally { + setLoading(false); + } + } + + useEffect(() => { loadCities(); }, []); + + function selectCity(city) { + setActiveCity(city); + localStorage.setItem(STORAGE_KEY, String(city.id)); + } + + function addCity(city) { + setCities(current => [...current, city].sort((left, right) => left.name.localeCompare(right.name, 'ru'))); + selectCity(city); + } + + return + {children} + ; +} + +export function useCity() { + const context = useContext(CityContext); + if (!context) throw new Error('useCity must be used within CityProvider'); + return context; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 4a85875..7848fd5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -29,6 +29,16 @@ .sidebar-content { @apply hidden min-h-0 flex-col px-4 pb-4; max-height: calc(100dvh - 68px); overflow-y: auto; } .sidebar-content.is-open { display: flex; } .sidebar-account { @apply flex items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 text-sm; min-height: 56px; } + .city-selector { position: relative; margin-top: 12px; } + .sidebar-city-card { @apply flex w-full items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 text-sm transition-colors hover:bg-gray-50; min-height: 64px; } + .city-emblem { @apply flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary-50 text-lg; overflow: hidden; } + .city-emblem.is-small { @apply h-8 w-8 text-base; } + .city-emblem.is-large { @apply h-14 w-14 rounded-xl text-3xl; } + .city-switcher { position: absolute; z-index: 50; top: calc(100% + 8px); left: 0; right: 0; min-width: 232px; padding: 8px; border-radius: 12px; background: #fff; box-shadow: 0 12px 32px #00000026, 0 2px 8px #0000000d; } + .city-switcher-heading { @apply px-3 pb-2 pt-1 text-xs uppercase tracking-wide text-gray-500; } + .city-switcher-option { @apply flex min-h-[44px] w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-gray-800 hover:bg-gray-50; } + .city-switcher-divider { height: 1px; margin: 6px 4px; background: #f0f0f0; } + .city-switcher-add { @apply text-primary-700; } .sidebar-group-title { @apply px-3 pb-2 pt-5 text-xs font-normal uppercase tracking-wide text-gray-500; } .sidebar-link { @apply flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-3 text-sm text-gray-600 transition-colors hover:bg-black/[0.04]; } .sidebar-link.is-active { @apply bg-black/[0.04] font-semibold text-gray-900; } @@ -50,6 +60,23 @@ .stat-card-icon { @apply flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 text-gray-600; } .stat-card-link { @apply mt-auto flex items-center justify-between border-t border-gray-100 pt-3 text-sm text-primary-600; } .dashboard-intro { @apply flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between; } + .cities-page { @apply space-y-6; } + .cities-heading { @apply flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between; } + .cities-heading > div > p { @apply mt-2 text-gray-500; } + .city-form { @apply space-y-5; } + .city-form h2 { @apply text-lg font-semibold; } + .city-form h2 + p { @apply mt-1 text-sm text-gray-500; } + .city-form-grid { @apply grid gap-4 sm:grid-cols-2; } + .city-form-hint { @apply mt-1 block text-xs text-gray-500; } + .city-form-error { @apply rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700; } + .cities-grid { @apply grid gap-4 md:grid-cols-2; } + .city-network-card { @apply flex items-center gap-4; min-height: 104px; } + .city-network-card.is-active { border-color: #0958d9; box-shadow: inset 0 0 0 1px #0958d9; } + .city-network-card h2 { @apply text-lg font-semibold; } + .city-network-card p { @apply mt-1 text-sm text-gray-500; } + .city-current { @apply inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; } + .city-isolation-note { @apply flex items-start gap-3 rounded-xl bg-primary-50 p-4 text-sm text-primary-700; } + .city-isolation-note svg { @apply mt-0.5 shrink-0; } .login-surface { @apply flex min-h-screen items-center justify-center p-4; background: #f5f5f5; } .login-card { @apply rounded-xl bg-white p-6 sm:p-8; } @media (max-width: 767px) { diff --git a/frontend/src/pages/CitiesPage.jsx b/frontend/src/pages/CitiesPage.jsx new file mode 100644 index 0000000..e7a4b47 --- /dev/null +++ b/frontend/src/pages/CitiesPage.jsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { CheckCircle2, MapPinned, Plus } from 'lucide-react'; +import { citiesApi } from '../services/api'; +import { CityEmblem } from '../components/CitySelector'; +import { useCity } from '../contexts/CityContext'; + +const transliteration = { + а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' +}; + +function slugFromName(name) { + return String(name || '').toLowerCase().split('').map(char => transliteration[char] ?? char) + .join('').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} + +const emptyForm = { name: '', slug: '', emblem: '🏙️', center_lat: '', center_lon: '' }; + +export default function CitiesPage() { + const { cities, activeCity, addCity } = useCity(); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(emptyForm); + const [slugEdited, setSlugEdited] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + function updateForm(field, value) { + setForm(current => { + const next = { ...current, [field]: value }; + if (field === 'name' && !slugEdited) next.slug = slugFromName(value); + return next; + }); + } + + async function submit(event) { + event.preventDefault(); + setSaving(true); setError(''); + try { + const { data } = await citiesApi.create(form); + addCity(data); + setForm(emptyForm); + setSlugEdited(false); + setShowForm(false); + } catch (requestError) { + setError(requestError.response?.data?.error || 'Не удалось добавить город.'); + } finally { + setSaving(false); + } + } + + return
+
+

Города

У каждого города — свои маршруты, остановки, расписания и уведомления.

+ +
+ + {showForm &&
+

Новый город

После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.

+ {error &&

{error}

} +
+ + + + + +
+
+
} + +
+ {cities.map(city =>
+ +

{city.name}

{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты можно указать при создании'}

+ {city.id === activeCity?.id && } +
)} +
+
+
; +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 1038990..070cc73 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -6,6 +6,7 @@ import { PageLoader } from '../components/AnimatedComponents'; import { AlertList } from '../components/AlertComponents'; import dashboardBackground from '../assets/dashboard-background.png'; import './Dashboard.css'; +import { useCity } from '../contexts/CityContext'; const TIME_ZONE = 'Europe/Minsk'; const numberFormat = new Intl.NumberFormat('ru-RU'); @@ -30,6 +31,7 @@ function formatDate(value, options) { } export default function Dashboard() { + const { activeCity } = useCity(); const [data, setData] = useState({ routes: null, stops: null, sync: null, holidays: null, alerts: null }); const [failed, setFailed] = useState([]); const [loading, setLoading] = useState(true); @@ -95,7 +97,7 @@ export default function Dashboard() { {!loaded ? : <>
-

Транспорт Полоцка

+

Транспорт {activeCity?.name}

{updatedAt ? `Обновлено в ${formatDate(updatedAt, { hour: '2-digit', minute: '2-digit' })}` : 'Ожидаем актуальные данные'}
diff --git a/frontend/src/pages/ScheduleImportPage.jsx b/frontend/src/pages/ScheduleImportPage.jsx index 5ca8d61..692ff42 100644 --- a/frontend/src/pages/ScheduleImportPage.jsx +++ b/frontend/src/pages/ScheduleImportPage.jsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { Download, ExternalLink, RefreshCw, Check, AlertCircle } from 'lucide-react'; import { routesApi, scheduleImportApi } from '../services/api'; import './ScheduleImportPage.css'; +import { useCity } from '../contexts/CityContext'; const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' }; const WEEKDAY_LABELS = { 1: 'пн', 2: 'вт', 3: 'ср', 4: 'чт', 5: 'пт', 6: 'сб', 7: 'вс' }; @@ -38,6 +39,7 @@ function TripList({ trips, stops, empty }) { } export default function ScheduleImportPage() { + const { activeCity } = useCity(); const [tab, setTab] = useState('schedule'); const [catalog, setCatalog] = useState([]); const [routes, setRoutes] = useState([]); @@ -100,6 +102,11 @@ export default function ScheduleImportPage() { const checkpointNames = names.filter(name => !inspection?.departureStops.includes(name)); const unusedOrigins = (inspection?.departureStops || []).filter(name => !usedNames.has(name)); + if (!activeCity?.schedule_source_url) return
+

Импорт расписаний

Для города «{activeCity?.name}» официальный источник импорта пока не настроен.

+
Данные этого города изолированы от Полоцка. Создайте его маршруты, остановки и расписания вручную — импорт Полоцка здесь недоступен.
+
; + return

Импорт расписаний

diff --git a/frontend/src/pages/SchedulesPage.jsx b/frontend/src/pages/SchedulesPage.jsx index d3154d9..4d4e853 100644 --- a/frontend/src/pages/SchedulesPage.jsx +++ b/frontend/src/pages/SchedulesPage.jsx @@ -6,6 +6,7 @@ import { routesApi, routeStopsApi, schedulesApi, holidaysApi } from '../services import { PageLoader } from '../components/AnimatedComponents'; import RouteBadge from '../components/RouteBadge'; import './SchedulesPage.css'; +import { useCity } from '../contexts/CityContext'; const DAY_TYPES = [ { value: 'weekday', label: 'Будние дни', description: 'Пн-Пт' }, @@ -26,6 +27,7 @@ const WEEKDAYS = [ export default function SchedulesPage() { const { isAdmin } = useAuth(); + const { activeCity } = useCity(); const [searchParams] = useSearchParams(); const requestedRouteId = searchParams.get('route'); const [routes, setRoutes] = useState([]); @@ -176,7 +178,7 @@ export default function SchedulesPage() {

Расписания

- {isAdmin && Импорт с сайта} + {isAdmin && activeCity?.schedule_source_url && Импорт с сайта}
{/* Upcoming Holidays Info */} diff --git a/frontend/src/pages/StopsPage.jsx b/frontend/src/pages/StopsPage.jsx index 92c9c75..8a91d1a 100644 --- a/frontend/src/pages/StopsPage.jsx +++ b/frontend/src/pages/StopsPage.jsx @@ -6,6 +6,7 @@ import StopForm from '../components/StopForm'; import { PageLoader } from '../components/AnimatedComponents'; import 'leaflet/dist/leaflet.css'; import L from 'leaflet'; +import { useCity } from '../contexts/CityContext'; // Fix default marker icon delete L.Icon.Default.prototype._getIconUrl; @@ -15,15 +16,18 @@ L.Icon.Default.mergeOptions({ shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', }); -const POLOTSK_CENTER = [55.4869, 28.7856]; // Полоцк координаты +const DEFAULT_CENTER = [55.4869, 28.7856]; export default function StopsPage() { + const { activeCity } = useCity(); const [stops, setStops] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingStop, setEditingStop] = useState(null); const [newStopLocation, setNewStopLocation] = useState(null); const [mobileView, setMobileView] = useState('map'); // 'map' or 'list' + const cityCenter = activeCity?.center_lat != null && activeCity?.center_lon != null + ? [Number(activeCity.center_lat), Number(activeCity.center_lon)] : DEFAULT_CENTER; useEffect(() => { loadStops(); @@ -141,7 +145,7 @@ export default function StopsPage() {
@@ -247,4 +251,4 @@ function MapClickHandler({ onMapClick }) { } }); return null; -} \ No newline at end of file +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 47829d7..a8981b1 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -29,6 +29,11 @@ api.interceptors.request.use( // For all other endpoints, try to use JWT token first const accessToken = localStorage.getItem('accessToken'); + const activeCityId = localStorage.getItem('activeCityId'); + + if (activeCityId) { + config.headers['X-City-Id'] = activeCityId; + } if (accessToken) { // Use JWT token if available @@ -94,6 +99,11 @@ export const routesApi = { delete: (id) => api.delete(`/routes/${id}`) }; +export const citiesApi = { + getAll: () => api.get('/cities'), + create: (data) => api.post('/cities', data) +}; + // Route Stops API (привязка остановок к маршрутам) export const routeStopsApi = { getRouteStops: (routeId) => api.get(`/routes/${routeId}/stops`),