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