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 pool = require('../config/database');
const logger = require('../config/logger'); const logger = require('../config/logger');
const { getLocalDateTime, getIsoWeekday } = require('../utils/etaTime'); const { getLocalDateTime, getIsoWeekday } = require('../utils/etaTime');
const { getRouteInCity, getStopInCity } = require('../middleware/city');
class EtaController { class EtaController {
// Calculate ETA for a stop on a route // Calculate ETA for a stop on a route
@@ -17,6 +18,13 @@ class EtaController {
} }
try { 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) // Get schedule type for today (holiday-aware)
const typeResult = await pool.query( const typeResult = await pool.query(
'SELECT get_schedule_type($1::date) as schedule_type', 'SELECT get_schedule_type($1::date) as schedule_type',
@@ -138,13 +146,16 @@ class EtaController {
const { stopId } = req.params; const { stopId } = req.params;
try { try {
if (!await getStopInCity(stopId, req.city.id)) {
return res.status(404).json({ error: 'Stop not found' });
}
// Get all routes passing through this stop // Get all routes passing through this stop
const routesResult = await pool.query(` const routesResult = await pool.query(`
SELECT DISTINCT r.id, r.route_number, r.name, r.type, r.color SELECT DISTINCT r.id, r.route_number, r.name, r.type, r.color
FROM routes r FROM routes r
JOIN route_stops rs ON r.id = rs.route_id 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
`, [stopId]); `, [stopId, req.city.id]);
const etaPromises = routesResult.rows.map(route => const etaPromises = routesResult.rows.map(route =>
this.calculateEtaForRoute(stopId, route.id) this.calculateEtaForRoute(stopId, route.id)

View File

@@ -9,7 +9,7 @@ class QualityController {
async getReport(req, res) { async getReport(req, res) {
try { try {
res.json(await this.report(this.pool)); res.json(await this.report(this.pool, req.city.id));
} catch (error) { } catch (error) {
logger.error('Error building quality report', { message: error.message }); logger.error('Error building quality report', { message: error.message });
res.status(500).json({ error: 'Не удалось проверить качество данных. Повторите попытку.' }); res.status(500).json({ error: 'Не удалось проверить качество данных. Повторите попытку.' });

View File

@@ -1,6 +1,7 @@
const pool = require('../config/database'); const pool = require('../config/database');
const redisClient = require('../config/redis'); const redisClient = require('../config/redis');
const logger = require('../config/logger'); const logger = require('../config/logger');
const { getRouteInCity, getStopInCity } = require('../middleware/city');
class RouteStopsController { class RouteStopsController {
// Get all stops for a route (ordered by sequence) // Get all stops for a route (ordered by sequence)
@@ -20,9 +21,10 @@ class RouteStopsController {
ST_X(s.location::geometry) as longitude ST_X(s.location::geometry) as longitude
FROM route_stops rs FROM route_stops rs
JOIN stops s ON rs.stop_id = s.id 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 ORDER BY rs.sequence
`, [routeId]); `, [routeId, req.city.id]);
res.json(result.rows); res.json(result.rows);
} catch (error) { } catch (error) {
@@ -44,20 +46,14 @@ class RouteStopsController {
try { try {
// Check if route exists // Check if route exists
const routeCheck = await pool.query( const route = await getRouteInCity(routeId, req.city.id);
'SELECT id FROM routes WHERE id = $1', if (!route) {
[routeId]
);
if (routeCheck.rows.length === 0) {
return res.status(404).json({ error: 'Route not found' }); return res.status(404).json({ error: 'Route not found' });
} }
// Check if stop exists // Check if stop exists
const stopCheck = await pool.query( const stop = await getStopInCity(stopId, req.city.id);
'SELECT id FROM stops WHERE id = $1', if (!stop) {
[stopId]
);
if (stopCheck.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' }); return res.status(404).json({ error: 'Stop not found' });
} }
@@ -88,8 +84,8 @@ class RouteStopsController {
`, [routeId, stopId, actualSequence, timeOffsetMinutes]); `, [routeId, stopId, actualSequence, timeOffsetMinutes]);
// Invalidate cache // Invalidate cache
await redisClient.del(`route:${routeId}`); await redisClient.del(`route:${req.city.id}:${routeId}`);
await redisClient.del('routes:all'); await redisClient.del(`routes:all:${req.city.id}`);
logger.info(`Stop ${stopId} added to route ${routeId} at position ${actualSequence}`); logger.info(`Stop ${stopId} added to route ${routeId} at position ${actualSequence}`);
res.status(201).json(result.rows[0]); res.status(201).json(result.rows[0]);
@@ -105,6 +101,9 @@ class RouteStopsController {
const { sequence, timeOffsetMinutes } = req.body; const { sequence, timeOffsetMinutes } = req.body;
try { try {
if (!await getRouteInCity(routeId, req.city.id)) {
return res.status(404).json({ error: 'Route not found' });
}
// Get current route_stop // Get current route_stop
const current = await pool.query( const current = await pool.query(
'SELECT * FROM route_stops WHERE id = $1 AND route_id = $2', 'SELECT * FROM route_stops WHERE id = $1 AND route_id = $2',
@@ -151,7 +150,7 @@ class RouteStopsController {
`, [sequence, timeOffsetMinutes, routeStopId, routeId]); `, [sequence, timeOffsetMinutes, routeStopId, routeId]);
// Invalidate cache // Invalidate cache
await redisClient.del(`route:${routeId}`); await redisClient.del(`route:${req.city.id}:${routeId}`);
logger.info(`Route stop ${routeStopId} updated`); logger.info(`Route stop ${routeStopId} updated`);
res.json(result.rows[0]); res.json(result.rows[0]);
@@ -166,6 +165,9 @@ class RouteStopsController {
const { routeId, routeStopId } = req.params; const { routeId, routeStopId } = req.params;
try { 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 // Get the stop being removed to know its sequence
const current = await pool.query( const current = await pool.query(
'SELECT sequence FROM route_stops WHERE id = $1 AND route_id = $2', 'SELECT sequence FROM route_stops WHERE id = $1 AND route_id = $2',
@@ -192,8 +194,8 @@ class RouteStopsController {
`, [routeId, removedSequence]); `, [routeId, removedSequence]);
// Invalidate cache // Invalidate cache
await redisClient.del(`route:${routeId}`); await redisClient.del(`route:${req.city.id}:${routeId}`);
await redisClient.del('routes:all'); await redisClient.del(`routes:all:${req.city.id}`);
logger.info(`Stop removed from route ${routeId}`); logger.info(`Stop removed from route ${routeId}`);
res.json({ message: 'Stop removed from route successfully' }); res.json({ message: 'Stop removed from route successfully' });
@@ -218,6 +220,17 @@ class RouteStopsController {
const client = await pool.connect(); const client = await pool.connect();
try { 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'); await client.query('BEGIN');
// Delete all existing route_stops for this route // Delete all existing route_stops for this route
@@ -237,8 +250,8 @@ class RouteStopsController {
await client.query('COMMIT'); await client.query('COMMIT');
// Invalidate cache // Invalidate cache
await redisClient.del(`route:${routeId}`); await redisClient.del(`route:${req.city.id}:${routeId}`);
await redisClient.del('routes:all'); await redisClient.del(`routes:all:${req.city.id}`);
logger.info(`Route ${routeId} stops reordered: ${stops.length} stops`); logger.info(`Route ${routeId} stops reordered: ${stops.length} stops`);
res.json({ message: 'Route stops reordered successfully', count: stops.length }); res.json({ message: 'Route stops reordered successfully', count: stops.length });
@@ -264,12 +277,13 @@ class RouteStopsController {
ST_Y(s.location::geometry) as latitude, ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude ST_X(s.location::geometry) as longitude
FROM stops s 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 ( AND s.id NOT IN (
SELECT stop_id FROM route_stops WHERE route_id = $1 SELECT stop_id FROM route_stops WHERE route_id = $1
) )
ORDER BY s.name ORDER BY s.name
`, [routeId]); `, [routeId, req.city.id]);
res.json(result.rows); res.json(result.rows);
} catch (error) { } 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 // Get all routes
async getAllRoutes(req, res) { async getAllRoutes(req, res) {
try { try {
const cityId = req.city.id;
// Try cache first // Try cache first
const cached = await redisClient.get('routes:all'); const cacheKey = `routes:all:${cityId}`;
const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
} }
@@ -20,15 +22,15 @@ class RoutesController {
COUNT(DISTINCT rs.stop_id) as stops_count COUNT(DISTINCT rs.stop_id) as stops_count
FROM routes r FROM routes r
LEFT JOIN route_stops rs ON r.id = rs.route_id 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 GROUP BY r.id
ORDER BY r.route_number ORDER BY r.route_number
`); `, [cityId]);
const routes = result.rows; const routes = result.rows;
// Cache the result // 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); res.json(routes);
} catch (error) { } catch (error) {
@@ -40,9 +42,10 @@ class RoutesController {
// Get route by ID with stops // Get route by ID with stops
async getRouteById(req, res) { async getRouteById(req, res) {
const { id } = req.params; const { id } = req.params;
const cityId = req.city.id;
try { try {
const cacheKey = `route:${id}`; const cacheKey = `route:${cityId}:${id}`;
const cached = await redisClient.get(cacheKey); const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
@@ -50,8 +53,8 @@ class RoutesController {
// Get route info // Get route info
const routeResult = await pool.query( const routeResult = await pool.query(
'SELECT * FROM routes WHERE id = $1 AND is_active = true', 'SELECT * FROM routes WHERE id = $1 AND city_id = $2 AND is_active = true',
[id] [id, cityId]
); );
if (routeResult.rows.length === 0) { if (routeResult.rows.length === 0) {
@@ -68,9 +71,9 @@ class RoutesController {
rs.time_offset_minutes rs.time_offset_minutes
FROM stops s FROM stops s
JOIN route_stops rs ON s.id = rs.stop_id 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 ORDER BY rs.sequence
`, [id]); `, [id, cityId]);
const route = { const route = {
...routeResult.rows[0], ...routeResult.rows[0],
@@ -90,6 +93,7 @@ class RoutesController {
// Create new route // Create new route
async createRoute(req, res) { async createRoute(req, res) {
const { route_number, name, type, color, description } = req.body; const { route_number, name, type, color, description } = req.body;
const cityId = req.city.id;
if (!route_number || !name || !type) { if (!route_number || !name || !type) {
return res.status(400).json({ return res.status(400).json({
@@ -100,13 +104,13 @@ class RoutesController {
try { try {
const result = await pool.query(` const result = await pool.query(`
INSERT INTO routes (route_number, name, type, color, description) INSERT INTO routes (route_number, name, type, color, description, city_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING * RETURNING *
`, [route_number, name, type, color || '#0066CC', description]); `, [route_number, name, type, color || '#0066CC', description, cityId]);
// Invalidate cache // Invalidate cache
await redisClient.del('routes:all'); await redisClient.del(`routes:all:${cityId}`);
logger.info(`Route created: ${route_number} - ${name}`); logger.info(`Route created: ${route_number} - ${name}`);
res.status(201).json(result.rows[0]); res.status(201).json(result.rows[0]);
@@ -120,6 +124,7 @@ class RoutesController {
async updateRoute(req, res) { async updateRoute(req, res) {
const { id } = req.params; const { id } = req.params;
const { route_number, name, type, color, description, is_active } = req.body; const { route_number, name, type, color, description, is_active } = req.body;
const cityId = req.city.id;
try { try {
const result = await pool.query(` const result = await pool.query(`
@@ -131,17 +136,17 @@ class RoutesController {
color = COALESCE($4, color), color = COALESCE($4, color),
description = COALESCE($5, description), description = COALESCE($5, description),
is_active = COALESCE($6, is_active) is_active = COALESCE($6, is_active)
WHERE id = $7 WHERE id = $7 AND city_id = $8
RETURNING * 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) { if (result.rows.length === 0) {
return res.status(404).json({ error: 'Route not found' }); return res.status(404).json({ error: 'Route not found' });
} }
// Invalidate cache // Invalidate cache
await redisClient.del('routes:all'); await redisClient.del(`routes:all:${cityId}`);
await redisClient.del(`route:${id}`); await redisClient.del(`route:${cityId}:${id}`);
logger.info(`Route updated: ${id}`); logger.info(`Route updated: ${id}`);
res.json(result.rows[0]); res.json(result.rows[0]);
@@ -154,11 +159,12 @@ class RoutesController {
// Delete route // Delete route
async deleteRoute(req, res) { async deleteRoute(req, res) {
const { id } = req.params; const { id } = req.params;
const cityId = req.city.id;
try { try {
const result = await pool.query( const result = await pool.query(
'DELETE FROM routes WHERE id = $1 RETURNING *', 'DELETE FROM routes WHERE id = $1 AND city_id = $2 RETURNING *',
[id] [id, cityId]
); );
if (result.rows.length === 0) { if (result.rows.length === 0) {
@@ -166,8 +172,8 @@ class RoutesController {
} }
// Invalidate cache // Invalidate cache
await redisClient.del('routes:all'); await redisClient.del(`routes:all:${cityId}`);
await redisClient.del(`route:${id}`); await redisClient.del(`route:${cityId}:${id}`);
logger.info(`Route deleted: ${id}`); logger.info(`Route deleted: ${id}`);
res.json({ message: 'Route deleted successfully' }); res.json({ message: 'Route deleted successfully' });

View File

@@ -1,6 +1,7 @@
const pool = require('../config/database'); const pool = require('../config/database');
const redisClient = require('../config/redis'); const redisClient = require('../config/redis');
const logger = require('../config/logger'); const logger = require('../config/logger');
const { getRouteInCity } = require('../middleware/city');
const VALID_DAY_TYPES = ['weekday', 'saturday', 'sunday', 'holiday']; const VALID_DAY_TYPES = ['weekday', 'saturday', 'sunday', 'holiday'];
@@ -17,7 +18,7 @@ class SchedulesController {
r.name as route_name r.name as route_name
FROM schedules s FROM schedules s
JOIN routes r ON s.route_id = r.id 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 ORDER BY
CASE s.day_type CASE s.day_type
WHEN 'weekday' THEN 1 WHEN 'weekday' THEN 1
@@ -25,7 +26,7 @@ class SchedulesController {
WHEN 'sunday' THEN 3 WHEN 'sunday' THEN 3
WHEN 'holiday' THEN 4 WHEN 'holiday' THEN 4
END END
`, [routeId]); `, [routeId, req.city.id]);
res.json(result.rows.map(schedule => this.withScheduleTrips(schedule))); res.json(result.rows.map(schedule => this.withScheduleTrips(schedule)));
} catch (error) { } catch (error) {
@@ -46,8 +47,8 @@ class SchedulesController {
r.name as route_name r.name as route_name
FROM schedules s FROM schedules s
JOIN routes r ON s.route_id = r.id JOIN routes r ON s.route_id = r.id
WHERE s.id = $1 WHERE s.id = $1 AND r.city_id = $2
`, [id]); `, [id, req.city.id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(404).json({ error: 'Schedule not found' }); return res.status(404).json({ error: 'Schedule not found' });
@@ -88,6 +89,9 @@ class SchedulesController {
} }
try { 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 // Check if schedule already exists for this route and day type
const existing = await pool.query(` const existing = await pool.query(`
SELECT id FROM schedules SELECT id FROM schedules
@@ -117,7 +121,7 @@ class SchedulesController {
]); ]);
// Invalidate cache // 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}`); logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`);
res.status(201).json(this.withScheduleTrips(result.rows[0])); res.status(201).json(this.withScheduleTrips(result.rows[0]));
@@ -134,7 +138,11 @@ class SchedulesController {
try { try {
// Get current schedule to find route_id // 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) { if (current.rows.length === 0) {
return res.status(404).json({ error: 'Schedule not found' }); return res.status(404).json({ error: 'Schedule not found' });
} }
@@ -169,7 +177,7 @@ class SchedulesController {
]); ]);
// Invalidate cache // 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`); logger.info(`Schedule ${id} updated`);
res.json(this.withScheduleTrips(result.rows[0])); res.json(this.withScheduleTrips(result.rows[0]));
@@ -185,8 +193,10 @@ class SchedulesController {
try { try {
const result = await pool.query( const result = await pool.query(
'DELETE FROM schedules WHERE id = $1 RETURNING route_id', `DELETE FROM schedules s USING routes r
[id] 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) { if (result.rows.length === 0) {
@@ -194,7 +204,7 @@ class SchedulesController {
} }
// Invalidate cache // 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`); logger.info(`Schedule ${id} deleted`);
res.json({ message: 'Schedule deleted successfully' }); res.json({ message: 'Schedule deleted successfully' });
@@ -226,12 +236,12 @@ class SchedulesController {
SELECT s.*, r.route_number, r.name as route_name SELECT s.*, r.route_number, r.name as route_name
FROM schedules s FROM schedules s
JOIN routes r ON s.route_id = r.id 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.day_type = $2
AND (s.valid_from IS NULL OR s.valid_from <= $3::date) AND (s.valid_from IS NULL OR s.valid_from <= $3::date)
AND (s.valid_until IS NULL OR s.valid_until >= $3::date) AND (s.valid_until IS NULL OR s.valid_until >= $3::date)
LIMIT 1 LIMIT 1
`, [routeId, scheduleType, targetDate]); `, [routeId, scheduleType, targetDate, req.city.id]);
// If holiday and no specific holiday schedule, fall back to sunday // If holiday and no specific holiday schedule, fall back to sunday
if (result.rows.length === 0 && scheduleType === 'holiday') { if (result.rows.length === 0 && scheduleType === 'holiday') {
@@ -239,12 +249,12 @@ class SchedulesController {
SELECT s.*, r.route_number, r.name as route_name SELECT s.*, r.route_number, r.name as route_name
FROM schedules s FROM schedules s
JOIN routes r ON s.route_id = r.id 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.day_type = 'sunday'
AND (s.valid_from IS NULL OR s.valid_from <= $2::date) AND (s.valid_from IS NULL OR s.valid_from <= $2::date)
AND (s.valid_until IS NULL OR s.valid_until >= $2::date) AND (s.valid_until IS NULL OR s.valid_until >= $2::date)
LIMIT 1 LIMIT 1
`, [routeId, targetDate]); `, [routeId, targetDate, req.city.id]);
} }
if (result.rows.length === 0) { if (result.rows.length === 0) {
@@ -287,6 +297,9 @@ class SchedulesController {
} }
try { try {
if (!await getRouteInCity(routeId, req.city.id)) {
return res.status(404).json({ error: 'Route not found' });
}
// Get source schedule // Get source schedule
const source = await pool.query(` const source = await pool.query(`
SELECT departure_times, trips, valid_from, valid_until FROM schedules SELECT departure_times, trips, valid_from, valid_until FROM schedules
@@ -352,7 +365,7 @@ class SchedulesController {
} }
// Invalidate cache // 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}`); logger.info(`Schedule copied from ${fromDayType} to ${toDayType} for route ${routeId}`);
} catch (error) { } catch (error) {

View File

@@ -8,7 +8,9 @@ class StopsController {
// Get all stops // Get all stops
async getAllStops(req, res) { async getAllStops(req, res) {
try { 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) { if (cached) {
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
} }
@@ -21,13 +23,13 @@ class StopsController {
COUNT(DISTINCT rs.route_id) as routes_count COUNT(DISTINCT rs.route_id) as routes_count
FROM stops s FROM stops s
LEFT JOIN route_stops rs ON s.id = rs.stop_id 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 GROUP BY s.id
ORDER BY s.name ORDER BY s.name
`); `, [cityId]);
const stops = result.rows; 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); res.json(stops);
} catch (error) { } catch (error) {
@@ -39,6 +41,7 @@ class StopsController {
// Get stop by ID // Get stop by ID
async getStopById(req, res) { async getStopById(req, res) {
const { id } = req.params; const { id } = req.params;
const cityId = req.city.id;
try { try {
const result = await pool.query(` const result = await pool.query(`
@@ -47,8 +50,8 @@ class StopsController {
ST_Y(s.location::geometry) as latitude, ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude ST_X(s.location::geometry) as longitude
FROM stops s FROM stops s
WHERE s.id = $1 AND s.is_active = true WHERE s.id = $1 AND s.city_id = $2 AND s.is_active = true
`, [id]); `, [id, cityId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' }); return res.status(404).json({ error: 'Stop not found' });
@@ -62,9 +65,9 @@ class StopsController {
rs.time_offset_minutes rs.time_offset_minutes
FROM routes r FROM routes r
JOIN route_stops rs ON r.id = rs.route_id 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 ORDER BY r.route_number
`, [id]); `, [id, cityId]);
const stop = { const stop = {
...result.rows[0], ...result.rows[0],
@@ -81,6 +84,7 @@ class StopsController {
// Get nearby stops (PostGIS query) // Get nearby stops (PostGIS query)
async getNearbyStops(req, res) { async getNearbyStops(req, res) {
const { lat, lon, radius = 500 } = req.query; const { lat, lon, radius = 500 } = req.query;
const cityId = req.city.id;
if (!lat || !lon) { if (!lat || !lon) {
return res.status(400).json({ return res.status(400).json({
@@ -100,7 +104,7 @@ class StopsController {
ST_MakePoint($2, $1)::geography ST_MakePoint($2, $1)::geography
) as distance_meters ) as distance_meters
FROM stops s FROM stops s
WHERE s.is_active = true WHERE s.is_active = true AND s.city_id = $4
AND ST_DWithin( AND ST_DWithin(
s.location, s.location,
ST_MakePoint($2, $1)::geography, ST_MakePoint($2, $1)::geography,
@@ -108,7 +112,7 @@ class StopsController {
) )
ORDER BY distance_meters ORDER BY distance_meters
LIMIT 20 LIMIT 20
`, [parseFloat(lat), parseFloat(lon), parseFloat(radius)]); `, [parseFloat(lat), parseFloat(lon), parseFloat(radius), cityId]);
res.json(result.rows); res.json(result.rows);
} catch (error) { } catch (error) {
@@ -120,6 +124,7 @@ class StopsController {
// Create stop // Create stop
async createStop(req, res) { async createStop(req, res) {
const { name, latitude, longitude, address, description } = req.body; const { name, latitude, longitude, address, description } = req.body;
const cityId = req.city.id;
if (!name || !latitude || !longitude) { if (!name || !latitude || !longitude) {
return res.status(400).json({ return res.status(400).json({
@@ -130,20 +135,21 @@ class StopsController {
try { try {
const result = await pool.query(` const result = await pool.query(`
INSERT INTO stops (name, location, address, description) INSERT INTO stops (name, location, address, description, city_id)
VALUES ( VALUES (
$1, $1,
ST_SetSRID(ST_MakePoint($3, $2), 4326)::geography, ST_SetSRID(ST_MakePoint($3, $2), 4326)::geography,
$4, $4,
$5 $5,
$6
) )
RETURNING RETURNING
*, *,
ST_Y(location::geometry) as latitude, ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude 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}`); logger.info(`Stop created: ${name}`);
res.status(201).json(result.rows[0]); res.status(201).json(result.rows[0]);
@@ -157,6 +163,7 @@ class StopsController {
async updateStop(req, res) { async updateStop(req, res) {
const { id } = req.params; const { id } = req.params;
const { name, latitude, longitude, address, description, is_active } = req.body; const { name, latitude, longitude, address, description, is_active } = req.body;
const cityId = req.city.id;
try { try {
let query = 'UPDATE stops SET '; let query = 'UPDATE stops SET ';
@@ -190,8 +197,8 @@ class StopsController {
} }
query = query.slice(0, -2); // Remove trailing comma 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`; 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); values.push(id, cityId);
const result = await pool.query(query, values); const result = await pool.query(query, values);
@@ -199,7 +206,7 @@ class StopsController {
return res.status(404).json({ error: 'Stop not found' }); 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}`); logger.info(`Stop updated: ${id}`);
res.json(result.rows[0]); res.json(result.rows[0]);
@@ -212,18 +219,19 @@ class StopsController {
// Delete stop // Delete stop
async deleteStop(req, res) { async deleteStop(req, res) {
const { id } = req.params; const { id } = req.params;
const cityId = req.city.id;
try { try {
const result = await pool.query( const result = await pool.query(
'DELETE FROM stops WHERE id = $1 RETURNING *', 'DELETE FROM stops WHERE id = $1 AND city_id = $2 RETURNING *',
[id] [id, cityId]
); );
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' }); 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}`); logger.info(`Stop deleted: ${id}`);
res.json({ message: 'Stop deleted successfully' }); res.json({ message: 'Stop deleted successfully' });

View File

@@ -23,10 +23,11 @@ class SyncController {
// Full synchronization (all data) // Full synchronization (all data)
async fullSync(req, res) { async fullSync(req, res) {
try { try {
const cityId = req.city.id;
// Get all active routes // Get all active routes
const routesResult = await pool.query(` 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 // Get all active stops with coordinates
const stopsResult = await pool.query(` const stopsResult = await pool.query(`
@@ -35,8 +36,8 @@ class SyncController {
ST_Y(location::geometry) as latitude, ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude ST_X(location::geometry) as longitude
FROM stops FROM stops
WHERE is_active = true WHERE is_active = true AND city_id = $1
`); `, [cityId]);
// Get route-stop relationships // Get route-stop relationships
const routeStopsResult = await pool.query(` const routeStopsResult = await pool.query(`
@@ -45,25 +46,26 @@ class SyncController {
JOIN routes r ON rs.route_id = r.id JOIN routes r ON rs.route_id = r.id
JOIN stops s ON rs.stop_id = s.id JOIN stops s ON rs.stop_id = s.id
WHERE r.is_active = true AND s.is_active = true WHERE r.is_active = true AND s.is_active = true
`); AND r.city_id = $1 AND s.city_id = $1
`, [cityId]);
// Get schedules // Get schedules
const schedulesResult = await pool.query(` const schedulesResult = await pool.query(`
SELECT s.* SELECT s.*
FROM schedules s FROM schedules s
JOIN routes r ON s.route_id = r.id 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_from IS NULL OR s.valid_from <= CURRENT_DATE)
AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE) AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE)
`); `, [cityId]);
// Get active alerts // Get active alerts
const alertsResult = await pool.query(` const alertsResult = await pool.query(`
SELECT * FROM alerts SELECT * FROM alerts
WHERE is_active = true WHERE city_id = $1 AND is_active = true
AND start_time <= CURRENT_TIMESTAMP AND start_time <= CURRENT_TIMESTAMP
AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP) AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP)
`); `, [cityId]);
// Get holidays for current and next year // Get holidays for current and next year
const holidaysResult = await pool.query(` const holidaysResult = await pool.query(`
@@ -125,9 +127,9 @@ class SyncController {
action, action,
timestamp timestamp
FROM sync_log FROM sync_log
WHERE timestamp > $1 WHERE timestamp > $1 AND city_id = $2
ORDER BY table_name, record_id, timestamp DESC ORDER BY table_name, record_id, timestamp DESC
`, [lastSyncDate]); `, [lastSyncDate, req.city.id]);
const changes = { const changes = {
routes: { updated: [], deleted: [] }, routes: { updated: [], deleted: [] },
@@ -146,7 +148,7 @@ class SyncController {
} }
} else { } else {
// Get updated record // 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]) { if (record && changes[table_name]) {
changes[table_name].updated.push(record); changes[table_name].updated.push(record);
} }
@@ -156,9 +158,9 @@ class SyncController {
// Get new alerts // Get new alerts
const alertsResult = await pool.query(` const alertsResult = await pool.query(`
SELECT * FROM alerts SELECT * FROM alerts
WHERE is_active = true WHERE city_id = $2 AND is_active = true
AND created_at > $1 AND created_at > $1
`, [lastSyncDate]); `, [lastSyncDate, req.city.id]);
const syncData = { const syncData = {
syncType: 'incremental', syncType: 'incremental',
@@ -181,13 +183,13 @@ class SyncController {
} }
// Helper: Get record from table // Helper: Get record from table
async getRecord(tableName, recordId) { async getRecord(tableName, recordId, cityId) {
try { try {
let query = ''; let query = '';
switch (tableName) { switch (tableName) {
case 'routes': 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; break;
case 'stops': case 'stops':
query = ` query = `
@@ -196,16 +198,19 @@ class SyncController {
ST_Y(location::geometry) as latitude, ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude ST_X(location::geometry) as longitude
FROM stops FROM stops
WHERE id = $1 AND is_active = true WHERE id = $1 AND city_id = $2 AND is_active = true
`; `;
break; break;
case 'route_stops': 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; break;
case 'schedules': case 'schedules':
query = ` query = `
SELECT * FROM schedules SELECT schedules.* FROM schedules
WHERE id = $1 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_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
`; `;
@@ -214,7 +219,7 @@ class SyncController {
return null; 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; return result.rows.length > 0 ? result.rows[0] : null;
} catch (error) { } catch (error) {
@@ -226,13 +231,14 @@ class SyncController {
// Get sync status // Get sync status
async getSyncStatus(req, res) { async getSyncStatus(req, res) {
try { try {
const cityId = req.city.id;
const stats = await pool.query(` const stats = await pool.query(`
SELECT SELECT
(SELECT COUNT(*) FROM routes WHERE is_active = true) as routes_count, (SELECT COUNT(*) FROM routes WHERE is_active = true AND city_id = $1) as routes_count,
(SELECT COUNT(*) FROM stops WHERE is_active = true) as stops_count, (SELECT COUNT(*) FROM stops WHERE is_active = true AND city_id = $1) as stops_count,
(SELECT COUNT(*) FROM schedules) as schedules_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) as last_change (SELECT MAX(timestamp) FROM sync_log WHERE city_id = $1) as last_change
`); `, [cityId]);
res.json({ res.json({
status: 'ready', 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 logger = require('../config/logger');
const { getAlertCreationService } = require('../services/alertCreationService'); const { getAlertCreationService } = require('../services/alertCreationService');
const { listAlertTemplates } = require('../services/alertTemplates'); const { listAlertTemplates } = require('../services/alertTemplates');
const { getRouteInCity } = require('../middleware/city');
function validateAlertTimes({ start_time, end_time }) { function validateAlertTimes({ start_time, end_time }) {
const start = new Date(start_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 SELECT a.*, r.name as route_name, r.route_number, r.color
FROM alerts a FROM alerts a
LEFT JOIN routes r ON a.route_id = r.id LEFT JOIN routes r ON a.route_id = r.id
WHERE 1=1 WHERE a.city_id = $1
`; `;
const params = []; const params = [req.city.id];
let paramCount = 1; let paramCount = 2;
if (route_id) { if (route_id) {
query += ` AND a.route_id = $${paramCount}`; 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 SELECT a.*, r.name as route_name, r.route_number, r.color
FROM alerts a FROM alerts a
LEFT JOIN routes r ON a.route_id = r.id 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.start_time <= NOW()
AND (a.end_time IS NULL OR a.end_time >= NOW()) AND (a.end_time IS NULL OR a.end_time >= NOW())
`; `;
const params = []; const params = [req.city.id];
if (route_id) { if (route_id) {
query += ' AND a.route_id = $1'; query += ' AND a.route_id = $2';
params.push(route_id); 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 `SELECT a.*, r.name as route_name, r.route_number, r.color
FROM alerts a FROM alerts a
LEFT JOIN routes r ON a.route_id = r.id LEFT JOIN routes r ON a.route_id = r.id
WHERE a.id = $1`, WHERE a.id = $1 AND a.city_id = $2`,
[id] [id, req.city.id]
); );
if (result.rows.length === 0) { if (result.rows.length === 0) {
@@ -132,9 +134,13 @@ function createAlertHandler({ alertCreationService = null } = {}) {
return res.status(400).json({ error: 'Invalid alert_type' }); 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 service = alertCreationService || getAlertCreationService();
const result = await service.createAlert( 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') } { 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; const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
// Check if alert exists // 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) { if (checkResult.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' }); 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 (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( const result = await pool.query(
`UPDATE alerts `UPDATE alerts
SET route_id = COALESCE($1, route_id), SET route_id = COALESCE($1, route_id),
@@ -191,9 +204,9 @@ router.put('/:id', async (req, res) => {
start_time = COALESCE($5, start_time), start_time = COALESCE($5, start_time),
end_time = $6, end_time = $6,
is_active = COALESCE($7, is_active) is_active = COALESCE($7, is_active)
WHERE id = $8 WHERE id = $8 AND city_id = $9
RETURNING *`, 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]); res.json(result.rows[0]);
@@ -207,7 +220,7 @@ router.put('/:id', async (req, res) => {
router.delete('/:id', async (req, res) => { router.delete('/:id', async (req, res) => {
try { try {
const { id } = req.params; 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) { if (result.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' }); return res.status(404).json({ error: 'Alert not found' });
@@ -226,11 +239,11 @@ router.post('/cleanup', async (req, res) => {
const result = await pool.query( const result = await pool.query(
`UPDATE alerts `UPDATE alerts
SET is_active = false 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 IS NOT NULL
AND end_time < NOW() AND end_time < NOW()
RETURNING id` RETURNING id`
); , [req.city.id]);
res.json({ res.json({
message: 'Cleanup completed', message: 'Cleanup completed',

View File

@@ -13,8 +13,14 @@ const telemetryController = require('../controllers/telemetryController');
const passengerTelemetryController = require('../controllers/passengerTelemetryController'); const passengerTelemetryController = require('../controllers/passengerTelemetryController');
const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter'); const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter');
const { authenticateToken, requireAdmin } = require('../middleware/auth'); const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { resolveCity } = require('../middleware/city');
const qualityController = require('../controllers/qualityController'); 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.use('/schedule-import', require('./scheduleImport'));
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController)); 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 => { const service = new ScheduleImportService({ pool, invalidate: async routeId => {
if (!redis.isReady) throw new Error('Redis unavailable'); 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(); const router = express.Router();
router.use(authenticateToken, requireAdmin); 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) => { const handle = action => async (req, res) => {
try { res.json(await action(req)); } catch (error) { try { res.json(await action(req)); } catch (error) {
logger.error('Schedule import failed', { message: error.message }); logger.error('Schedule import failed', { message: error.message });

View File

@@ -2,8 +2,8 @@ const crypto = require('node:crypto');
const INSERT_ALERT_SQL = ` const INSERT_ALERT_SQL = `
WITH inserted AS ( WITH inserted AS (
INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING * RETURNING *
) )
SELECT inserted.*, routes.route_number AS notification_route_number SELECT inserted.*, routes.route_number AS notification_route_number
@@ -43,7 +43,8 @@ function normalizeAlertInput(payload) {
message: payload.message, message: payload.message,
start_time: payload.start_time, start_time: payload.start_time,
end_time: payload.end_time || null, 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), message: normalizeHashValue(alert.message),
start_time: normalizeHashValue(alert.start_time), start_time: normalizeHashValue(alert.start_time),
end_time: normalizeHashValue(alert.end_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 return crypto
@@ -146,7 +148,8 @@ class AlertCreationService {
alertInput.message, alertInput.message,
alertInput.start_time, alertInput.start_time,
alertInput.end_time, alertInput.end_time,
alertInput.is_active alertInput.is_active,
alertInput.city_id
]); ]);
const row = result.rows[0]; const row = result.rows[0];

View File

@@ -277,9 +277,23 @@ function buildQualityReport(rowsByCheck, checkedAt = new Date().toISOString()) {
return { checked_at: checkedAt, summary, issues }; return { checked_at: checkedAt, summary, issues };
} }
async function runQualityChecks(pool) { function withCityScope(query, cityId) {
const results = await Promise.all(qualityChecks.map(async ({ name, query }) => [name, (await pool.query(query)).rows])); 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)); return buildQualityReport(Object.fromEntries(results));
} }
module.exports = { buildQualityReport, qualityChecks, runQualityChecks }; module.exports = { buildQualityReport, qualityChecks, runQualityChecks, withCityScope };

View File

@@ -3,6 +3,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute'; import { ProtectedRoute } from './components/ProtectedRoute';
import AppLayout from './components/AppLayout'; import AppLayout from './components/AppLayout';
import { PageLoader } from './components/AnimatedComponents'; import { PageLoader } from './components/AnimatedComponents';
import { CityProvider, useCity } from './contexts/CityContext';
// Pages // Pages
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
@@ -17,6 +18,7 @@ import HolidaysPage from './pages/HolidaysPage';
import AlertsPage from './pages/AlertsPage'; import AlertsPage from './pages/AlertsPage';
import UsersPage from './pages/UsersPage'; import UsersPage from './pages/UsersPage';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import CitiesPage from './pages/CitiesPage';
function App() { function App() {
return ( return (
@@ -42,19 +44,26 @@ function AppContent() {
); );
} }
return ( return <CityProvider><CityWorkspace /></CityProvider>;
<AppLayout>
<AnimatedRoutes />
</AppLayout>
);
} }
function AnimatedRoutes() { function CityWorkspace() {
const { loading, error, activeCity } = useCity();
if (loading) return <PageLoader />;
if (error || !activeCity) return <main className="login-surface"><div className="login-card max-w-md"><h1 className="text-xl font-semibold">Города недоступны</h1><p className="mt-2 text-gray-600">{error || 'В системе нет активных городов.'}</p></div></main>;
return <AppLayout>
<AnimatedRoutes cityId={activeCity.id} />
</AppLayout>;
}
function AnimatedRoutes({ cityId }) {
const location = useLocation(); const location = useLocation();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
return ( return (
<div key={location.pathname} className="page-transition"> <div key={`${cityId}:${location.pathname}`} className="page-transition">
<Routes location={location}> <Routes location={location}>
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/routes" element={<ProtectedRoute><RoutesPage /></ProtectedRoute>} /> <Route path="/routes" element={<ProtectedRoute><RoutesPage /></ProtectedRoute>} />
@@ -66,6 +75,7 @@ function AnimatedRoutes() {
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} /> <Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
<Route path="/alerts" element={<ProtectedRoute><AlertsPage /></ProtectedRoute>} /> <Route path="/alerts" element={<ProtectedRoute><AlertsPage /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} /> <Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
{isAdmin && <Route path="/cities" element={<ProtectedRoute requireAdmin><CitiesPage /></ProtectedRoute>} />}
{isAdmin && ( {isAdmin && (
<Route path="/users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} /> <Route path="/users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
)} )}

View File

@@ -4,6 +4,8 @@ import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users,
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import BrandLogo from './BrandLogo'; import BrandLogo from './BrandLogo';
import ProfileMenu from './ProfileMenu'; import ProfileMenu from './ProfileMenu';
import CitySelector from './CitySelector';
import { useCity } from '../contexts/CityContext';
const navigationGroups = [ const navigationGroups = [
{ label: 'Обзор', items: [{ to: '/', label: 'Главная', icon: LayoutDashboard }] }, { label: 'Обзор', items: [{ to: '/', label: 'Главная', icon: LayoutDashboard }] },
@@ -24,6 +26,7 @@ const navigationGroups = [
export default function AppLayout({ children }) { export default function AppLayout({ children }) {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { activeCity } = useCity();
const { pathname } = useLocation(); const { pathname } = useLocation();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const menuButton = useRef(null); const menuButton = useRef(null);
@@ -55,6 +58,7 @@ export default function AppLayout({ children }) {
<div id="sidebar-content" className={`sidebar-content ${menuOpen ? 'is-open' : ''}`}> <div id="sidebar-content" className={`sidebar-content ${menuOpen ? 'is-open' : ''}`}>
<ProfileMenu /> <ProfileMenu />
<CitySelector />
<nav aria-label="Основная навигация" className="sidebar-navigation"> <nav aria-label="Основная навигация" className="sidebar-navigation">
{navigationGroups.map(group => ( {navigationGroups.map(group => (
<section key={group.label}> <section key={group.label}>
@@ -72,7 +76,7 @@ export default function AppLayout({ children }) {
))} ))}
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
<p className="px-3 pt-4 text-xs text-gray-500">Общественный транспорт Полоцка</p> <p className="px-3 pt-4 text-xs text-gray-500">Общественный транспорт {activeCity?.name}</p>
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -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 <span className={`city-emblem ${className}`} aria-hidden="true">{city?.emblem || <MapPinned size={20} />}</span>;
}
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 <div className="city-selector">
<button type="button" className="sidebar-city-card" aria-haspopup="listbox" aria-expanded={open}
onClick={() => setOpen(current => !current)}>
<CityEmblem city={activeCity} />
<span className="min-w-0 flex-1 text-left">
<span className="block truncate font-medium">{activeCity.name}</span>
<span className="block mt-0.5 text-xs text-gray-500">Транспортная сеть</span>
</span>
<ChevronDown size={14} aria-hidden="true" className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && <div className="city-switcher" role="listbox" aria-label="Выбор города">
<p className="city-switcher-heading">Города</p>
<div className="space-y-1">
{cities.map(city => <button type="button" key={city.id} role="option" aria-selected={city.id === activeCity.id}
className="city-switcher-option" onClick={() => { selectCity(city); setOpen(false); }}>
<CityEmblem city={city} className="is-small" />
<span className="min-w-0 flex-1 truncate">{city.name}</span>
{city.id === activeCity.id && <Check size={16} className="text-primary-600" aria-hidden="true" />}
</button>)}
</div>
{isAdmin && <>
<div className="city-switcher-divider" />
<Link to="/cities" className="city-switcher-option city-switcher-add" onClick={() => setOpen(false)}>
<Plus size={18} aria-hidden="true" /><span>{pathname === '/cities' ? 'Управление городами' : 'Добавить город'}</span>
</Link>
</>}
</div>}
</div>;
}
export { CityEmblem };

View File

@@ -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 <CityContext.Provider value={{ cities, activeCity, loading, error, selectCity, addCity, reloadCities: loadCities }}>
{children}
</CityContext.Provider>;
}
export function useCity() {
const context = useContext(CityContext);
if (!context) throw new Error('useCity must be used within CityProvider');
return context;
}

View File

@@ -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 { @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-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; } .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-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 { @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; } .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-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; } .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; } .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-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; } .login-card { @apply rounded-xl bg-white p-6 sm:p-8; }
@media (max-width: 767px) { @media (max-width: 767px) {

View File

@@ -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 <div className="cities-page">
<header className="cities-heading">
<div><h1 className="page-title">Города</h1><p>У каждого города — свои маршруты, остановки, расписания и уведомления.</p></div>
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={() => setShowForm(current => !current)}>
<Plus size={18} aria-hidden="true" />{showForm ? 'Закрыть форму' : 'Добавить город'}
</button>
</header>
{showForm && <form className="card city-form" onSubmit={submit}>
<div><h2>Новый город</h2><p>После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.</p></div>
{error && <p className="city-form-error" role="alert">{error}</p>}
<div className="city-form-grid">
<label><span className="label">Название</span><input required className="input" value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="Например, Новополоцк" /></label>
<label><span className="label">Код города</span><input required pattern="[a-z0-9]+(-[a-z0-9]+)*" className="input" value={form.slug} onChange={event => { setSlugEdited(true); updateForm('slug', event.target.value.toLowerCase()); }} placeholder="novopolotsk" /><span className="city-form-hint">Латинские буквы, цифры и дефисы.</span></label>
<label><span className="label">Иконка или герб</span><input maxLength="64" className="input" value={form.emblem} onChange={event => updateForm('emblem', event.target.value)} placeholder="🏙️" /></label>
<label><span className="label">Широта центра</span><input type="number" step="any" className="input" value={form.center_lat} onChange={event => updateForm('center_lat', event.target.value)} placeholder="55.5318" /></label>
<label><span className="label">Долгота центра</span><input type="number" step="any" className="input" value={form.center_lon} onChange={event => updateForm('center_lon', event.target.value)} placeholder="28.5987" /></label>
</div>
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={() => { setShowForm(false); setError(''); }}>Отмена</button></div>
</form>}
<section className="cities-grid" aria-label="Доступные города">
{cities.map(city => <article key={city.id} className={`card city-network-card ${city.id === activeCity?.id ? 'is-active' : ''}`}>
<CityEmblem city={city} className="is-large" />
<div className="min-w-0 flex-1"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты можно указать при создании'}</p></div>
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
</article>)}
</section>
<div className="city-isolation-note"><MapPinned size={20} aria-hidden="true" /><p>Переключение города в боковой панели меняет рабочий контур целиком: данные одного города не отображаются и не редактируются в другом.</p></div>
</div>;
}

View File

@@ -6,6 +6,7 @@ import { PageLoader } from '../components/AnimatedComponents';
import { AlertList } from '../components/AlertComponents'; import { AlertList } from '../components/AlertComponents';
import dashboardBackground from '../assets/dashboard-background.png'; import dashboardBackground from '../assets/dashboard-background.png';
import './Dashboard.css'; import './Dashboard.css';
import { useCity } from '../contexts/CityContext';
const TIME_ZONE = 'Europe/Minsk'; const TIME_ZONE = 'Europe/Minsk';
const numberFormat = new Intl.NumberFormat('ru-RU'); const numberFormat = new Intl.NumberFormat('ru-RU');
@@ -30,6 +31,7 @@ function formatDate(value, options) {
} }
export default function Dashboard() { export default function Dashboard() {
const { activeCity } = useCity();
const [data, setData] = useState({ routes: null, stops: null, sync: null, holidays: null, alerts: null }); const [data, setData] = useState({ routes: null, stops: null, sync: null, holidays: null, alerts: null });
const [failed, setFailed] = useState([]); const [failed, setFailed] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -95,7 +97,7 @@ export default function Dashboard() {
{!loaded ? <PageLoader /> : <> {!loaded ? <PageLoader /> : <>
<section className="dashboard-summary" aria-labelledby="summary-title"> <section className="dashboard-summary" aria-labelledby="summary-title">
<div className="dashboard-summary-heading"> <div className="dashboard-summary-heading">
<h2 id="summary-title">Транспорт Полоцка</h2> <h2 id="summary-title">Транспорт {activeCity?.name}</h2>
<span>{updatedAt ? `Обновлено в ${formatDate(updatedAt, { hour: '2-digit', minute: '2-digit' })}` : 'Ожидаем актуальные данные'}</span> <span>{updatedAt ? `Обновлено в ${formatDate(updatedAt, { hour: '2-digit', minute: '2-digit' })}` : 'Ожидаем актуальные данные'}</span>
</div> </div>
<div className="dashboard-summary-grid"> <div className="dashboard-summary-grid">

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { Download, ExternalLink, RefreshCw, Check, AlertCircle } from 'lucide-react'; import { Download, ExternalLink, RefreshCw, Check, AlertCircle } from 'lucide-react';
import { routesApi, scheduleImportApi } from '../services/api'; import { routesApi, scheduleImportApi } from '../services/api';
import './ScheduleImportPage.css'; import './ScheduleImportPage.css';
import { useCity } from '../contexts/CityContext';
const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' }; const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' };
const WEEKDAY_LABELS = { 1: 'пн', 2: 'вт', 3: 'ср', 4: 'чт', 5: 'пт', 6: 'сб', 7: 'вс' }; const WEEKDAY_LABELS = { 1: 'пн', 2: 'вт', 3: 'ср', 4: 'чт', 5: 'пт', 6: 'сб', 7: 'вс' };
@@ -38,6 +39,7 @@ function TripList({ trips, stops, empty }) {
} }
export default function ScheduleImportPage() { export default function ScheduleImportPage() {
const { activeCity } = useCity();
const [tab, setTab] = useState('schedule'); const [tab, setTab] = useState('schedule');
const [catalog, setCatalog] = useState([]); const [catalog, setCatalog] = useState([]);
const [routes, setRoutes] = useState([]); const [routes, setRoutes] = useState([]);
@@ -100,6 +102,11 @@ export default function ScheduleImportPage() {
const checkpointNames = names.filter(name => !inspection?.departureStops.includes(name)); const checkpointNames = names.filter(name => !inspection?.departureStops.includes(name));
const unusedOrigins = (inspection?.departureStops || []).filter(name => !usedNames.has(name)); const unusedOrigins = (inspection?.departureStops || []).filter(name => !usedNames.has(name));
if (!activeCity?.schedule_source_url) return <div className="schedule-import-page space-y-6">
<header><h1 className="page-title">Импорт расписаний</h1><p className="mt-2 text-gray-500">Для города «{activeCity?.name}» официальный источник импорта пока не настроен.</p></header>
<section className="card text-gray-600">Данные этого города изолированы от Полоцка. Создайте его маршруты, остановки и расписания вручную — импорт Полоцка здесь недоступен.</section>
</div>;
return <div className="schedule-import-page space-y-6" aria-busy={Boolean(busy)}> return <div className="schedule-import-page space-y-6" aria-busy={Boolean(busy)}>
<header> <header>
<h1 className="page-title">Импорт расписаний</h1> <h1 className="page-title">Импорт расписаний</h1>

View File

@@ -6,6 +6,7 @@ import { routesApi, routeStopsApi, schedulesApi, holidaysApi } from '../services
import { PageLoader } from '../components/AnimatedComponents'; import { PageLoader } from '../components/AnimatedComponents';
import RouteBadge from '../components/RouteBadge'; import RouteBadge from '../components/RouteBadge';
import './SchedulesPage.css'; import './SchedulesPage.css';
import { useCity } from '../contexts/CityContext';
const DAY_TYPES = [ const DAY_TYPES = [
{ value: 'weekday', label: 'Будние дни', description: 'Пн-Пт' }, { value: 'weekday', label: 'Будние дни', description: 'Пн-Пт' },
@@ -26,6 +27,7 @@ const WEEKDAYS = [
export default function SchedulesPage() { export default function SchedulesPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { activeCity } = useCity();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const requestedRouteId = searchParams.get('route'); const requestedRouteId = searchParams.get('route');
const [routes, setRoutes] = useState([]); const [routes, setRoutes] = useState([]);
@@ -176,7 +178,7 @@ export default function SchedulesPage() {
<div> <div>
<div className="flex justify-between items-center mb-4 sm:mb-6"> <div className="flex justify-between items-center mb-4 sm:mb-6">
<h1 className="page-title">Расписания</h1> <h1 className="page-title">Расписания</h1>
{isAdmin && <Link to="/schedule-import" className="btn-secondary">Импорт с сайта</Link>} {isAdmin && activeCity?.schedule_source_url && <Link to="/schedule-import" className="btn-secondary">Импорт с сайта</Link>}
</div> </div>
{/* Upcoming Holidays Info */} {/* Upcoming Holidays Info */}

View File

@@ -6,6 +6,7 @@ import StopForm from '../components/StopForm';
import { PageLoader } from '../components/AnimatedComponents'; import { PageLoader } from '../components/AnimatedComponents';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import L from 'leaflet'; import L from 'leaflet';
import { useCity } from '../contexts/CityContext';
// Fix default marker icon // Fix default marker icon
delete L.Icon.Default.prototype._getIconUrl; 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', 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() { export default function StopsPage() {
const { activeCity } = useCity();
const [stops, setStops] = useState([]); const [stops, setStops] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingStop, setEditingStop] = useState(null); const [editingStop, setEditingStop] = useState(null);
const [newStopLocation, setNewStopLocation] = useState(null); const [newStopLocation, setNewStopLocation] = useState(null);
const [mobileView, setMobileView] = useState('map'); // 'map' or 'list' 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(() => { useEffect(() => {
loadStops(); loadStops();
@@ -141,7 +145,7 @@ export default function StopsPage() {
<div className={`lg:col-span-2 ${mobileView === 'list' ? 'hidden lg:block' : ''}`}> <div className={`lg:col-span-2 ${mobileView === 'list' ? 'hidden lg:block' : ''}`}>
<div className="card p-0 overflow-hidden" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}> <div className="card p-0 overflow-hidden" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
<MapContainer <MapContainer
center={POLOTSK_CENTER} center={cityCenter}
zoom={13} zoom={13}
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
> >
@@ -247,4 +251,4 @@ function MapClickHandler({ onMapClick }) {
} }
}); });
return null; return null;
} }

View File

@@ -29,6 +29,11 @@ api.interceptors.request.use(
// For all other endpoints, try to use JWT token first // For all other endpoints, try to use JWT token first
const accessToken = localStorage.getItem('accessToken'); const accessToken = localStorage.getItem('accessToken');
const activeCityId = localStorage.getItem('activeCityId');
if (activeCityId) {
config.headers['X-City-Id'] = activeCityId;
}
if (accessToken) { if (accessToken) {
// Use JWT token if available // Use JWT token if available
@@ -94,6 +99,11 @@ export const routesApi = {
delete: (id) => api.delete(`/routes/${id}`) delete: (id) => api.delete(`/routes/${id}`)
}; };
export const citiesApi = {
getAll: () => api.get('/cities'),
create: (data) => api.post('/cities', data)
};
// Route Stops API (привязка остановок к маршрутам) // Route Stops API (привязка остановок к маршрутам)
export const routeStopsApi = { export const routeStopsApi = {
getRouteStops: (routeId) => api.get(`/routes/${routeId}/stops`), getRouteStops: (routeId) => api.get(`/routes/${routeId}/stops`),