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

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

View File

@@ -277,9 +277,23 @@ function buildQualityReport(rowsByCheck, checkedAt = new Date().toISOString()) {
return { checked_at: checkedAt, summary, issues };
}
async function runQualityChecks(pool) {
const results = await Promise.all(qualityChecks.map(async ({ name, query }) => [name, (await pool.query(query)).rows]));
function withCityScope(query, cityId) {
if (!cityId) return { query, params: [] };
return {
query: query
.replaceAll('r.is_active = true', 'r.is_active = true AND r.city_id = $1')
.replace('WHERE s.is_active = true', 'WHERE s.is_active = true AND s.city_id = $1')
.replace('WHERE a.is_active = true', 'WHERE a.is_active = true AND a.city_id = $1'),
params: [cityId]
};
}
async function runQualityChecks(pool, cityId = null) {
const results = await Promise.all(qualityChecks.map(async ({ name, query }) => {
const scoped = withCityScope(query, cityId);
return [name, (await pool.query(scoped.query, scoped.params)).rows];
}));
return buildQualityReport(Object.fromEntries(results));
}
module.exports = { buildQualityReport, qualityChecks, runQualityChecks };
module.exports = { buildQualityReport, qualityChecks, runQualityChecks, withCityScope };