scheduling feature

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 15:58:02 +02:00
parent c7874f2678
commit 68490f8fd5
17 changed files with 1855 additions and 3 deletions
+471
View File
@@ -16,10 +16,12 @@ const _mock = {
routeStops: [],
busLines: [],
fleetBuses: [],
schedules: [],
nextStopId: 1,
nextRouteStopId: 1,
nextBusLineId: 1,
nextFleetBusId: 1,
nextScheduleId: 1,
};
const adminUserCodes = new Set(
@@ -682,6 +684,161 @@ app.post('/admin/bus-lines/:busLineId/stops/reorder', authMiddleware, adminMiddl
}
});
// ============================================
// ADMIN - Delete a stop
// ============================================
app.delete('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { stopId } = req.params;
if (!stopId || !Number.isFinite(Number(stopId))) {
return res.status(400).json({ error: 'invalid_stop_id' });
}
if (DEV_MOCK) {
const index = _mock.stops.findIndex(s => Number(s.id) === Number(stopId));
if (index === -1) {
return res.status(404).json({ error: 'stop_not_found' });
}
const deletedStop = _mock.stops.splice(index, 1)[0];
return res.json({ success: true, stop: deletedStop });
}
const result = await db.query(
`DELETE FROM transit.stops WHERE id = $1 RETURNING id, name`,
[stopId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'stop_not_found' });
}
res.json({ success: true, stop: result.rows[0] });
} catch (err) {
console.error(err);
if (err.code === '23503') {
return res.status(409).json({ error: 'foreign_key_violation', message: 'This stop is referenced by route_stops' });
}
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
// ============================================
// ADMIN - Delete a bus line
// ============================================
app.delete('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async (req, res) => {
const client = await db.pool.connect();
try {
const { busLineId } = req.params;
if (!busLineId || !Number.isFinite(Number(busLineId))) {
return res.status(400).json({ error: 'invalid_bus_line_id' });
}
if (DEV_MOCK) {
const lineIndex = _mock.busLines.findIndex(bl => Number(bl.id) === Number(busLineId));
if (lineIndex === -1) {
return res.status(404).json({ error: 'bus_line_not_found' });
}
const deletedLine = _mock.busLines.splice(lineIndex, 1)[0];
// Also delete route_stops for this line
_mock.routeStops = _mock.routeStops.filter(rs => String(rs.busLineId) !== String(busLineId));
return res.json({ success: true, busLine: deletedLine });
}
await client.query('BEGIN');
// Delete associated route_stops first
await client.query(
`DELETE FROM transit.route_stops WHERE bus_line_id = $1`,
[busLineId]
);
// Delete the bus line
const result = await client.query(
`DELETE FROM transit.bus_lines WHERE id = $1 RETURNING id, line_number`,
[busLineId]
);
if (result.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'bus_line_not_found' });
}
await client.query('COMMIT');
res.json({ success: true, busLine: result.rows[0] });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
} finally {
client.release();
}
});
// ============================================
// ADMIN - Delete a route_stop from bus line
// ============================================
app.delete('/admin/bus-lines/:busLineId/stops/:routeStopId', authMiddleware, adminMiddleware, async (req, res) => {
const client = await db.pool.connect();
try {
const { busLineId, routeStopId } = req.params;
if (!busLineId || !Number.isFinite(Number(busLineId))) {
return res.status(400).json({ error: 'invalid_bus_line_id' });
}
if (!routeStopId || !Number.isFinite(Number(routeStopId))) {
return res.status(400).json({ error: 'invalid_route_stop_id' });
}
if (DEV_MOCK) {
const index = _mock.routeStops.findIndex(
rs => Number(rs.id) === Number(routeStopId) && String(rs.busLineId) === String(busLineId)
);
if (index === -1) {
return res.status(404).json({ error: 'route_stop_not_found' });
}
const deletedRouteStop = _mock.routeStops.splice(index, 1)[0];
return res.json({ success: true, routeStop: deletedRouteStop });
}
await client.query('BEGIN');
// Find and delete the route_stop
const result = await client.query(
`DELETE FROM transit.route_stops
WHERE id = $1 AND bus_line_id = $2
RETURNING id, stop_order`,
[routeStopId, busLineId]
);
if (result.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'route_stop_not_found' });
}
const deletedOrder = result.rows[0].stop_order;
// Re-order remaining stops
await client.query(
`UPDATE transit.route_stops
SET stop_order = stop_order - 1, updated_at = NOW()
WHERE bus_line_id = $1 AND stop_order > $2`,
[busLineId, deletedOrder]
);
await client.query('COMMIT');
res.json({ success: true, routeStop: result.rows[0] });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
} finally {
client.release();
}
});
// ============================================
// NAVIGATION - Save calculated route
// ============================================
@@ -839,6 +996,320 @@ app.patch('/routes/:routeId/status', authMiddleware, async (req, res) => {
}
});
// ============================================
// ADMIN - Manage schedules (timetable)
// ============================================
app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
try {
if (DEV_MOCK) {
return res.json({ schedules: _mock.schedules || [] });
}
const result = await db.query(`
SELECT
s.id,
s.bus_line_id,
s.bus_id,
s.driver_id,
s.start_time,
s.end_time,
s.status,
s.created_at,
bl.line_number,
b.name as bus_name,
b.license_plate
FROM transit.schedules s
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
LEFT JOIN fleet.bus b ON s.bus_id = b.id
ORDER BY s.start_time DESC
`);
res.json({
schedules: result.rows.map(row => ({
id: row.id,
busLineId: row.bus_line_id,
busId: row.bus_id,
driverId: row.driver_id,
startTime: row.start_time,
endTime: row.end_time,
status: row.status,
lineNumber: row.line_number,
busName: row.bus_name,
licensePlate: row.license_plate,
createdAt: row.created_at,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { busLineId, busId, driverId, startTime, endTime } = req.body;
if (!busLineId || !busId || !startTime || !endTime) {
return res.status(400).json({ error: 'missing_required_fields' });
}
if (DEV_MOCK) {
if (!_mock.schedules) _mock.schedules = [];
const schedule = {
id: (_mock.nextScheduleId || 1),
busLineId,
busId,
driverId: driverId || null,
startTime,
endTime,
status: 'planned',
createdAt: new Date().toISOString(),
};
_mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1);
_mock.schedules.push(schedule);
return res.status(201).json({ schedule });
}
const result = await db.query(`
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules
)
INSERT INTO transit.schedules (id, bus_line_id, bus_id, driver_id, start_time, end_time, status)
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned'
FROM next_id
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at
`, [busLineId, busId, driverId || null, startTime, endTime]);
const schedule = result.rows[0];
res.status(201).json({
schedule: {
id: schedule.id,
busLineId: schedule.bus_line_id,
busId: schedule.bus_id,
driverId: schedule.driver_id,
startTime: schedule.start_time,
endTime: schedule.end_time,
status: schedule.status,
createdAt: schedule.created_at,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { scheduleId } = req.params;
if (!scheduleId || !Number.isFinite(Number(scheduleId))) {
return res.status(400).json({ error: 'invalid_schedule_id' });
}
if (DEV_MOCK) {
if (!_mock.schedules) _mock.schedules = [];
const index = _mock.schedules.findIndex(s => Number(s.id) === Number(scheduleId));
if (index === -1) {
return res.status(404).json({ error: 'schedule_not_found' });
}
const deleted = _mock.schedules.splice(index, 1)[0];
return res.json({ success: true, schedule: deleted });
}
const result = await db.query(
`DELETE FROM transit.schedules WHERE id = $1 RETURNING id`,
[scheduleId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'schedule_not_found' });
}
res.json({ success: true, schedule: result.rows[0] });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
// ============================================
// PUBLIC - Get schedules (for public timetable)
// ============================================
app.get('/schedules', async (req, res) => {
try {
const { date, lineId } = req.query;
if (DEV_MOCK) {
return res.json({ schedules: _mock.schedules || [] });
}
let query = `
SELECT
s.id,
s.bus_line_id,
s.bus_id,
s.start_time,
s.end_time,
s.status,
bl.line_number,
b.name as bus_name
FROM transit.schedules s
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
LEFT JOIN fleet.bus b ON s.bus_id = b.id
WHERE s.status IN ('planned', 'active')
`;
const params = [];
if (date) {
query += ` AND DATE(s.start_time) = $${params.length + 1}`;
params.push(date);
}
if (lineId) {
query += ` AND s.bus_line_id = $${params.length + 1}`;
params.push(lineId);
}
query += ` ORDER BY s.start_time ASC`;
const result = await db.query(query, params);
res.json({
schedules: result.rows.map(row => ({
id: row.id,
busLineId: row.bus_line_id,
busId: row.bus_id,
startTime: row.start_time,
endTime: row.end_time,
status: row.status,
lineNumber: row.line_number,
busName: row.bus_name,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
app.get('/schedules/:scheduleId', async (req, res) => {
try {
const { scheduleId } = req.params;
if (DEV_MOCK) {
const schedule = (_mock.schedules || []).find(s => Number(s.id) === Number(scheduleId));
if (!schedule) {
return res.status(404).json({ error: 'schedule_not_found' });
}
return res.json({ schedule });
}
const result = await db.query(`
SELECT
s.id,
s.bus_line_id,
s.bus_id,
s.start_time,
s.end_time,
s.status,
bl.line_number,
bl.start_stop,
bl.end_stop,
b.name as bus_name,
b.license_plate
FROM transit.schedules s
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
LEFT JOIN fleet.bus b ON s.bus_id = b.id
WHERE s.id = $1
`, [scheduleId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'schedule_not_found' });
}
const row = result.rows[0];
res.json({
schedule: {
id: row.id,
busLineId: row.bus_line_id,
busId: row.bus_id,
startTime: row.start_time,
endTime: row.end_time,
status: row.status,
lineNumber: row.line_number,
startStop: row.start_stop,
endStop: row.end_stop,
busName: row.bus_name,
licensePlate: row.license_plate,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
// ============================================
// PUBLIC - Get stops for a schedule's line
// ============================================
app.get('/schedules/:scheduleId/stops', async (req, res) => {
try {
const { scheduleId } = req.params;
if (DEV_MOCK) {
const schedule = (_mock.schedules || []).find(s => Number(s.id) === Number(scheduleId));
if (!schedule) {
return res.status(404).json({ error: 'schedule_not_found' });
}
const routeStops = (_mock.routeStops || [])
.filter(rs => Number(rs.busLineId) === Number(schedule.busLineId))
.sort((a, b) => a.stopOrder - b.stopOrder);
return res.json({ stops: routeStops });
}
const scheduleResult = await db.query(
`SELECT bus_line_id FROM transit.schedules WHERE id = $1`,
[scheduleId]
);
if (scheduleResult.rows.length === 0) {
return res.status(404).json({ error: 'schedule_not_found' });
}
const busLineId = scheduleResult.rows[0].bus_line_id;
const stopsResult = await db.query(`
SELECT
rs.id,
rs.stop_order,
s.name,
s.latitude,
s.longitude,
rs.estimated_arrival_minutes
FROM transit.route_stops rs
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = $1
ORDER BY rs.stop_order ASC
`, [busLineId]);
res.json({
stops: stopsResult.rows.map(row => ({
id: row.id,
order: row.stop_order,
name: row.name,
latitude: parseFloat(row.latitude),
longitude: parseFloat(row.longitude),
estimatedArrivalMinutes: row.estimated_arrival_minutes,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
const PORT = process.env.PORT || 5001;
app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));