0da6d07283
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Successful in 1m3s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 56s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m4s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m20s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m12s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
5357 lines
159 KiB
JavaScript
5357 lines
159 KiB
JavaScript
const express = require("express");
|
|
const cors = require("cors");
|
|
require("dotenv").config();
|
|
|
|
const db = require("./db");
|
|
const { authMiddleware } = require("./middleware");
|
|
|
|
function log(level, message, meta = {}) {
|
|
const timestamp = new Date().toISOString();
|
|
console.log(JSON.stringify({ timestamp, level, service: "transit_api", message, ...meta }));
|
|
}
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(cors());
|
|
|
|
const ERROR_LOG_API_URL =
|
|
process.env.ERROR_LOG_API_URL ||
|
|
"https://error.xtransit.testinstance.nl/error_log";
|
|
|
|
function isValidDateKey(value) {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ""))) return false;
|
|
const date = new Date(`${value}T00:00:00Z`);
|
|
return (
|
|
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
|
);
|
|
}
|
|
|
|
function friendlyErrorMessage(statusCode) {
|
|
if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
|
|
if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
|
|
if (statusCode === 403) return "Je hebt hier geen toegang toe.";
|
|
if (statusCode === 404) return "Niet gevonden.";
|
|
return "Er ging iets mis. Probeer het later opnieuw.";
|
|
}
|
|
|
|
async function reportError({ partofservice, error }) {
|
|
try {
|
|
await fetch(ERROR_LOG_API_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
service: "transit_api",
|
|
partofservice,
|
|
error,
|
|
}),
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
|
|
app.use((req, res, next) => {
|
|
const originalJson = res.json.bind(res);
|
|
res.json = (body) => {
|
|
if (res.statusCode >= 400 && body && typeof body === "object") {
|
|
const rawError =
|
|
body.error || body.message || body.details || `HTTP ${res.statusCode}`;
|
|
void reportError({
|
|
partofservice: `${req.method} ${req.originalUrl}`,
|
|
error: rawError,
|
|
});
|
|
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
|
|
}
|
|
return originalJson(body);
|
|
};
|
|
|
|
next();
|
|
});
|
|
|
|
app.get("/health", async (_req, res) => {
|
|
try {
|
|
await db.query("SELECT 1");
|
|
res.json({ status: "ok", service: "transit_api", database: "ok" });
|
|
} catch (err) {
|
|
console.error("GET /health error:", err);
|
|
res.status(503).json({ status: "unavailable", service: "transit_api" });
|
|
}
|
|
});
|
|
|
|
// Development mock storage when DEV_MOCK=1
|
|
const DEV_MOCK = process.env.DEV_MOCK === "1";
|
|
const _mock = {
|
|
stops: [],
|
|
routeStops: [],
|
|
busLines: [],
|
|
fleetBuses: [],
|
|
schedules: [],
|
|
scheduleStopProgress: [],
|
|
drivers: [],
|
|
nextStopId: 1,
|
|
nextRouteStopId: 1,
|
|
nextBusLineId: 1,
|
|
nextFleetBusId: 1,
|
|
nextScheduleId: 1,
|
|
nextScheduleStopProgressId: 1,
|
|
nextDriverId: 1,
|
|
};
|
|
|
|
// Initialize mock drivers
|
|
if (DEV_MOCK) {
|
|
_mock.drivers = [
|
|
{
|
|
id: 1,
|
|
name: "Jan Peterse",
|
|
email: "jan@transit.local",
|
|
phone: "06-12345678",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Maria Kuijpers",
|
|
email: "maria@transit.local",
|
|
phone: "06-87654321",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "Robert Smits",
|
|
email: "robert@transit.local",
|
|
phone: "06-55555555",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: 4,
|
|
name: "Anja Voorn",
|
|
email: "anja@transit.local",
|
|
phone: "06-44444444",
|
|
status: "inactive",
|
|
},
|
|
];
|
|
_mock.nextDriverId = 5;
|
|
|
|
// Initialize test bus lines
|
|
_mock.busLines = [
|
|
{
|
|
id: 1,
|
|
lineNumber: 1,
|
|
startStop: "Central Station",
|
|
endStop: "Airport",
|
|
estimatedDurationMinutes: 45,
|
|
description: "Main route",
|
|
},
|
|
{
|
|
id: 2,
|
|
lineNumber: 2,
|
|
startStop: "Central Station",
|
|
endStop: "Harbor",
|
|
estimatedDurationMinutes: 30,
|
|
description: "Harbor route",
|
|
},
|
|
];
|
|
_mock.nextBusLineId = 3;
|
|
|
|
// Initialize test stops
|
|
_mock.stops = [
|
|
{
|
|
id: 1,
|
|
name: "Central Station",
|
|
latitude: 52.0116,
|
|
longitude: 4.7055,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "City Center",
|
|
latitude: 52.0125,
|
|
longitude: 4.71,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "Airport",
|
|
latitude: 52.03,
|
|
longitude: 4.75,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
_mock.nextStopId = 4;
|
|
|
|
// Initialize test route stops
|
|
_mock.routeStops = [
|
|
{
|
|
id: 1,
|
|
busLineId: 1,
|
|
stopId: 1,
|
|
stopOrder: 1,
|
|
estimatedArrivalMinutes: 0,
|
|
name: "Central Station",
|
|
latitude: 52.0116,
|
|
longitude: 4.7055,
|
|
},
|
|
{
|
|
id: 2,
|
|
busLineId: 1,
|
|
stopId: 2,
|
|
stopOrder: 2,
|
|
estimatedArrivalMinutes: 15,
|
|
name: "City Center",
|
|
latitude: 52.0125,
|
|
longitude: 4.71,
|
|
},
|
|
{
|
|
id: 3,
|
|
busLineId: 1,
|
|
stopId: 3,
|
|
stopOrder: 3,
|
|
estimatedArrivalMinutes: 45,
|
|
name: "Airport",
|
|
latitude: 52.03,
|
|
longitude: 4.75,
|
|
},
|
|
];
|
|
_mock.nextRouteStopId = 4;
|
|
|
|
// Initialize test fleet buses
|
|
_mock.fleetBuses = [
|
|
{
|
|
id: 1,
|
|
name: "Bus 101",
|
|
seatCapacity: 45,
|
|
licensePlate: "AB-01-CD",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Bus 102",
|
|
seatCapacity: 50,
|
|
licensePlate: "AB-02-CD",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
_mock.nextFleetBusId = 3;
|
|
|
|
// Initialize test schedule
|
|
const now = new Date();
|
|
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
tomorrow.setHours(10, 0, 0, 0);
|
|
const endTime = new Date(tomorrow.getTime() + 45 * 60 * 1000);
|
|
|
|
_mock.schedules = [
|
|
{
|
|
id: 1,
|
|
busLineId: 1,
|
|
busId: 1,
|
|
driverId: null,
|
|
startTime: tomorrow.toISOString(),
|
|
endTime: endTime.toISOString(),
|
|
weekdays: [],
|
|
status: "planned",
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
_mock.nextScheduleId = 2;
|
|
}
|
|
|
|
const adminMiddleware = (req, res, next) => {
|
|
if (!req.user || !req.user.isAdmin) {
|
|
return res.status(403).json({ error: "forbidden" });
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
// ============================================
|
|
// HELPER FUNCTIONS
|
|
// ============================================
|
|
|
|
/**
|
|
* Calculate departure times for each stop in a route
|
|
* Returns array of { order, stopName, departureTime }
|
|
*/
|
|
function calculateDepartureTimes(startTime, busLineId, mock) {
|
|
const startDate = new Date(startTime);
|
|
|
|
// Get route stops for this bus line
|
|
const routeStops = mock.routeStops
|
|
.filter((rs) => rs.busLineId === busLineId)
|
|
.sort((a, b) => a.stopOrder - b.stopOrder);
|
|
|
|
return routeStops.map((rs) => {
|
|
const departureDate = new Date(startDate);
|
|
// Add estimated arrival minutes to get departure time
|
|
departureDate.setMinutes(
|
|
departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0),
|
|
);
|
|
|
|
const stop = mock.stops.find((s) => s.id === rs.stopId);
|
|
return {
|
|
order: rs.stopOrder,
|
|
stopId: rs.stopId,
|
|
stopName: stop?.name || "Unknown Stop",
|
|
estimatedArrivalMinutes: rs.estimatedArrivalMinutes || 0,
|
|
departureTime: departureDate.toISOString(),
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseWeekdaysValue(value) {
|
|
if (!value) return [];
|
|
if (Array.isArray(value))
|
|
return value.map((v) => Number(v)).filter((v) => Number.isFinite(v));
|
|
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return [];
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.map((v) => Number(v)).filter((v) => Number.isFinite(v));
|
|
}
|
|
} catch (_) {
|
|
// Fall through to CSV/PG-array parsing.
|
|
}
|
|
|
|
// Handles values like "1,2,3" or "{1,2,3}".
|
|
const normalized = trimmed.replace("{", "").replace("}", "");
|
|
return normalized
|
|
.split(",")
|
|
.map((v) => Number(v.trim()))
|
|
.filter((v) => Number.isFinite(v));
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function toNumberOrNull(value) {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
const numberValue = Number(value);
|
|
return Number.isFinite(numberValue) ? numberValue : null;
|
|
}
|
|
|
|
function parseCommaSeparatedValues(value) {
|
|
if (!value && value !== 0) return [];
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => String(item).trim()).filter(Boolean);
|
|
}
|
|
|
|
return String(value)
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function toDateKey(value) {
|
|
if (!value) return null;
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function expandRecurringScheduleRows(rows) {
|
|
const expandedRows = [];
|
|
|
|
for (const row of rows) {
|
|
const weekdays = parseWeekdaysValue(row.weekdays);
|
|
if (!weekdays || weekdays.length === 0) {
|
|
expandedRows.push(row);
|
|
continue;
|
|
}
|
|
|
|
const templateDate = new Date(row.start_time);
|
|
if (Number.isNaN(templateDate.getTime())) {
|
|
expandedRows.push(row);
|
|
continue;
|
|
}
|
|
|
|
const templateDayOfWeek = templateDate.getDay();
|
|
const sortedWeekdays = [...new Set(weekdays)].sort((a, b) => a - b);
|
|
const durationMinutes = Number(
|
|
row.estimated_duration_minutes ?? row.estimatedDurationMinutes ?? 60,
|
|
);
|
|
|
|
for (let week = 0; week < 52; week++) {
|
|
for (const targetWeekday of sortedWeekdays) {
|
|
const daysOffset = (targetWeekday - templateDayOfWeek + 7) % 7;
|
|
const totalDays = week * 7 + daysOffset;
|
|
|
|
const scheduleDate = new Date(templateDate);
|
|
scheduleDate.setDate(scheduleDate.getDate() + totalDays);
|
|
|
|
const endDate = new Date(scheduleDate);
|
|
endDate.setMinutes(endDate.getMinutes() + durationMinutes);
|
|
|
|
expandedRows.push({
|
|
...row,
|
|
start_time: scheduleDate.toISOString(),
|
|
end_time: endDate.toISOString(),
|
|
is_recurring_instance: true,
|
|
template_id: row.id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return expandedRows;
|
|
}
|
|
|
|
function mapAdminScheduleRow(row, routeStopsByLine = new Map()) {
|
|
const weekdays = parseWeekdaysValue(row.weekdays);
|
|
const busLineId = Number(row.busLineId ?? row.bus_line_id);
|
|
const startTime = row.startTime ?? row.start_time;
|
|
const routeStops = routeStopsByLine.get(busLineId) || [];
|
|
const departureTimes = buildDepartureTimes(startTime, routeStops);
|
|
|
|
return {
|
|
id: Number(row.id),
|
|
busLineId,
|
|
busId: Number(row.busId ?? row.bus_id),
|
|
driverId:
|
|
row.driverId !== null && row.driverId !== undefined
|
|
? Number(row.driverId)
|
|
: null,
|
|
driverName: row.driverName ?? row.driver_name ?? null,
|
|
startTime,
|
|
endTime: row.endTime ?? row.end_time,
|
|
weekdays,
|
|
departureTimes,
|
|
status: row.status,
|
|
lineNumber: Number(row.lineNumber ?? row.line_number) || null,
|
|
busName: row.busName ?? row.bus_name ?? "Unknown Bus",
|
|
licensePlate: row.licensePlate ?? row.license_plate ?? null,
|
|
createdAt: row.createdAt ?? row.created_at ?? null,
|
|
isRecurringInstance: row.is_recurring_instance || false,
|
|
templateId: row.template_id || row.templateId || null,
|
|
recurrenceLabel: formatWeekdayLabel(weekdays),
|
|
isRecurring: weekdays.length > 0,
|
|
};
|
|
}
|
|
|
|
function scheduleMatchesFilters(
|
|
schedule,
|
|
filters,
|
|
routeStopsByLine = new Map(),
|
|
) {
|
|
if (!schedule || !filters) return true;
|
|
|
|
const scheduleBusLineId = Number(schedule.bus_line_id ?? schedule.busLineId);
|
|
const scheduleBusId = Number(schedule.bus_id ?? schedule.busId);
|
|
const scheduleDriverId = Number(schedule.driver_id ?? schedule.driverId);
|
|
const scheduleStatus = String(schedule.status || "");
|
|
const scheduleDateKey = toDateKey(schedule.start_time ?? schedule.startTime);
|
|
|
|
if (filters.busLineId !== null && scheduleBusLineId !== filters.busLineId) {
|
|
return false;
|
|
}
|
|
|
|
if (filters.busId !== null && scheduleBusId !== filters.busId) {
|
|
return false;
|
|
}
|
|
|
|
if (filters.driverId !== null && scheduleDriverId !== filters.driverId) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
filters.statuses.length > 0 &&
|
|
!filters.statuses.includes(scheduleStatus)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (filters.date && scheduleDateKey !== filters.date) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
filters.fromDate &&
|
|
scheduleDateKey &&
|
|
scheduleDateKey < filters.fromDate
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (filters.toDate && scheduleDateKey && scheduleDateKey > filters.toDate) {
|
|
return false;
|
|
}
|
|
|
|
if (filters.stopId !== null) {
|
|
const routeStops = routeStopsByLine.get(scheduleBusLineId) || [];
|
|
const matchesStop = routeStops.some(
|
|
(routeStop) =>
|
|
Number(routeStop.stop_id ?? routeStop.stopId) === filters.stopId,
|
|
);
|
|
if (!matchesStop) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function parseScheduleFilters(query, options = {}) {
|
|
const statuses = parseCommaSeparatedValues(
|
|
query.status || query.statuses || options.statuses || [],
|
|
);
|
|
|
|
return {
|
|
date: query.date ? String(query.date).trim() : null,
|
|
fromDate: query.fromDate ? String(query.fromDate).trim() : null,
|
|
toDate: query.toDate ? String(query.toDate).trim() : null,
|
|
driverId: toNumberOrNull(
|
|
query.driverId ??
|
|
query.driver_id ??
|
|
query.chauffeurId ??
|
|
query.chauffeur_id,
|
|
),
|
|
busId: toNumberOrNull(query.busId ?? query.bus_id),
|
|
busLineId: toNumberOrNull(
|
|
query.busLineId ?? query.lineId ?? query.bus_line_id,
|
|
),
|
|
stopId: toNumberOrNull(
|
|
query.stopId ?? query.stop_id ?? query.busStopId ?? query.bus_stop_id,
|
|
),
|
|
statuses,
|
|
};
|
|
}
|
|
|
|
function formatWeekdayLabel(weekdays) {
|
|
const labels = ["Zo", "Ma", "Di", "Wo", "Do", "Vr", "Za"];
|
|
|
|
if (!Array.isArray(weekdays) || weekdays.length === 0) {
|
|
return "Eenmalig";
|
|
}
|
|
|
|
if (weekdays.length === 7) {
|
|
return "Dagelijks";
|
|
}
|
|
|
|
return [...new Set(weekdays)]
|
|
.sort((a, b) => a - b)
|
|
.filter((day) => day >= 0 && day < labels.length)
|
|
.map((day) => labels[day])
|
|
.join(", ");
|
|
}
|
|
|
|
function buildDepartureTimes(startTime, routeStops = []) {
|
|
const startDate = new Date(startTime);
|
|
|
|
return routeStops.map((routeStop) => {
|
|
const departureDate = new Date(startDate);
|
|
const etaMinutes =
|
|
Number(
|
|
routeStop.estimated_arrival_minutes ??
|
|
routeStop.estimatedArrivalMinutes,
|
|
) || 0;
|
|
departureDate.setMinutes(departureDate.getMinutes() + etaMinutes);
|
|
|
|
return {
|
|
order: Number(routeStop.stop_order ?? routeStop.stopOrder),
|
|
stopId: Number(routeStop.stop_id ?? routeStop.stopId),
|
|
stopName: routeStop.stop_name || routeStop.name || "Unknown Stop",
|
|
estimatedArrivalMinutes: etaMinutes,
|
|
departureTime: departureDate.toISOString(),
|
|
};
|
|
});
|
|
}
|
|
|
|
function isRecurringSchedule(schedule) {
|
|
return parseWeekdaysValue(schedule.weekdays).length > 0;
|
|
}
|
|
|
|
function scheduleOccursOnDate(schedule, dateKey) {
|
|
if (!dateKey) return true;
|
|
|
|
const weekdays = parseWeekdaysValue(schedule.weekdays);
|
|
const scheduleDateKey = toDateKey(schedule.start_time ?? schedule.startTime);
|
|
|
|
if (!scheduleDateKey) {
|
|
return false;
|
|
}
|
|
|
|
if (weekdays.length === 0) {
|
|
return scheduleDateKey === dateKey;
|
|
}
|
|
|
|
if (scheduleDateKey > dateKey) {
|
|
return false;
|
|
}
|
|
|
|
const targetDate = new Date(`${dateKey}T00:00:00`);
|
|
return weekdays.includes(targetDate.getDay());
|
|
}
|
|
|
|
function materializeScheduleOverviewRow(
|
|
row,
|
|
dateKey,
|
|
routeStopsByLine = new Map(),
|
|
) {
|
|
const weekdays = parseWeekdaysValue(row.weekdays);
|
|
const recurring = weekdays.length > 0;
|
|
const templateStart = new Date(row.startTime ?? row.start_time);
|
|
const templateEnd = new Date(row.endTime ?? row.end_time);
|
|
let startTime = row.startTime ?? row.start_time;
|
|
let endTime = row.endTime ?? row.end_time;
|
|
|
|
if (recurring && dateKey) {
|
|
const occurrenceStart = new Date(`${dateKey}T00:00:00`);
|
|
occurrenceStart.setHours(
|
|
templateStart.getHours(),
|
|
templateStart.getMinutes(),
|
|
templateStart.getSeconds(),
|
|
templateStart.getMilliseconds(),
|
|
);
|
|
|
|
startTime = occurrenceStart.toISOString();
|
|
|
|
if (
|
|
Number.isFinite(templateEnd.getTime()) &&
|
|
Number.isFinite(templateStart.getTime())
|
|
) {
|
|
const durationMillis = templateEnd.getTime() - templateStart.getTime();
|
|
const occurrenceEnd = new Date(occurrenceStart);
|
|
occurrenceEnd.setMilliseconds(
|
|
occurrenceEnd.getMilliseconds() + durationMillis,
|
|
);
|
|
endTime = occurrenceEnd.toISOString();
|
|
}
|
|
}
|
|
|
|
const busLineId = Number(row.busLineId ?? row.bus_line_id);
|
|
const routeStops = routeStopsByLine.get(busLineId) || [];
|
|
const departureTimes = buildDepartureTimes(startTime, routeStops);
|
|
|
|
return {
|
|
id: Number(row.id),
|
|
busLineId,
|
|
busId: Number(row.busId ?? row.bus_id),
|
|
// Hide driver information in public schedule materialization for privacy
|
|
driverId: null,
|
|
driverName: null,
|
|
startTime,
|
|
endTime,
|
|
weekdays,
|
|
status: row.status,
|
|
lineNumber: Number(row.lineNumber ?? row.line_number) || null,
|
|
busName: row.busName ?? row.bus_name ?? "Unknown Bus",
|
|
licensePlate: row.licensePlate ?? row.license_plate ?? null,
|
|
createdAt: row.createdAt ?? row.created_at ?? null,
|
|
scheduleType: recurring ? "recurring" : "one_time",
|
|
recurrenceLabel: formatWeekdayLabel(weekdays),
|
|
isRecurringInstance: recurring && Boolean(dateKey),
|
|
templateId: row.templateId ?? row.template_id ?? null,
|
|
departureTimes,
|
|
};
|
|
}
|
|
|
|
function mapRecurringOccurrenceRows(rows, dateKey, routeStopsByLine) {
|
|
return rows
|
|
.filter((row) => scheduleOccursOnDate(row, dateKey))
|
|
.map((row) =>
|
|
materializeScheduleOverviewRow(row, dateKey, routeStopsByLine),
|
|
)
|
|
.sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
|
|
}
|
|
|
|
function materializeScheduleRowsInRange(
|
|
rows,
|
|
fromDateKey,
|
|
toDateKey,
|
|
routeStopsByLine,
|
|
) {
|
|
const occurrences = [];
|
|
if (!fromDateKey || !toDateKey) return occurrences;
|
|
|
|
const fromDate = new Date(`${fromDateKey}T00:00:00`);
|
|
const toDate = new Date(`${toDateKey}T23:59:59`);
|
|
|
|
// Safety cap: don't generate more than 366 days
|
|
const maxDays = 366;
|
|
const spanDays = Math.ceil((toDate - fromDate) / (24 * 60 * 60 * 1000));
|
|
if (spanDays > maxDays) return occurrences;
|
|
|
|
for (const row of rows) {
|
|
const weekdays = parseWeekdaysValue(row.weekdays);
|
|
|
|
if (!weekdays || weekdays.length === 0) {
|
|
const rowDateKey = toDateKey(row.start_time ?? row.startTime);
|
|
if (rowDateKey && rowDateKey >= fromDateKey && rowDateKey <= toDateKey) {
|
|
occurrences.push(
|
|
materializeScheduleOverviewRow(row, rowDateKey, routeStopsByLine),
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// recurring template: generate occurrences per weekday within range
|
|
const templateStart = new Date(row.start_time ?? row.startTime);
|
|
for (const wd of [...new Set(weekdays)]) {
|
|
// find first date >= fromDate that matches weekday wd and is >= templateStart
|
|
let cursor = new Date(fromDate);
|
|
const fromWeekday = cursor.getDay();
|
|
const daysOffset = (wd - fromWeekday + 7) % 7;
|
|
cursor.setDate(cursor.getDate() + daysOffset);
|
|
|
|
// ensure cursor >= templateStart (so recurrence doesn't start before template)
|
|
if (cursor < templateStart) {
|
|
// advance by weeks until >= templateStart
|
|
const diffDays = Math.ceil(
|
|
(templateStart - cursor) / (24 * 60 * 60 * 1000),
|
|
);
|
|
const weeksToAdvance = Math.ceil(diffDays / 7);
|
|
cursor.setDate(cursor.getDate() + weeksToAdvance * 7);
|
|
}
|
|
|
|
while (cursor <= toDate) {
|
|
const occurrenceKey = cursor.toISOString().slice(0, 10);
|
|
occurrences.push(
|
|
materializeScheduleOverviewRow(row, occurrenceKey, routeStopsByLine),
|
|
);
|
|
cursor.setDate(cursor.getDate() + 7);
|
|
}
|
|
}
|
|
}
|
|
|
|
// sort and return
|
|
return occurrences.sort(
|
|
(a, b) => new Date(a.startTime) - new Date(b.startTime),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if two schedules have date/weekday overlap
|
|
*/
|
|
function doSchedulesDateOverlap(
|
|
schedule1Start,
|
|
schedule1End,
|
|
schedule1Weekdays,
|
|
schedule2Start,
|
|
schedule2End,
|
|
schedule2Weekdays,
|
|
) {
|
|
const s1Start = new Date(schedule1Start);
|
|
const s1End = new Date(schedule1End);
|
|
const s2Start = new Date(schedule2Start);
|
|
const s2End = new Date(schedule2End);
|
|
|
|
// Check time overlap
|
|
if (s1End <= s2Start || s2End <= s1Start) {
|
|
return false; // No time overlap
|
|
}
|
|
|
|
// If both have no weekdays (one-time), check if same day
|
|
if (
|
|
(!schedule1Weekdays || schedule1Weekdays.length === 0) &&
|
|
(!schedule2Weekdays || schedule2Weekdays.length === 0)
|
|
) {
|
|
return s1Start.toDateString() === s2Start.toDateString();
|
|
}
|
|
|
|
// If one has weekdays and other doesn't, check if one-time falls on weekday
|
|
if (
|
|
schedule1Weekdays &&
|
|
schedule1Weekdays.length > 0 &&
|
|
(!schedule2Weekdays || schedule2Weekdays.length === 0)
|
|
) {
|
|
const s2Day = s2Start.getDay(); // 0=Sun, 1=Mon, etc.
|
|
const s2Adjusted = s2Day === 0 ? 0 : s2Day; // Normalize to 0-6
|
|
return schedule1Weekdays.includes(s2Adjusted);
|
|
}
|
|
|
|
if (
|
|
(!schedule1Weekdays || schedule1Weekdays.length === 0) &&
|
|
schedule2Weekdays &&
|
|
schedule2Weekdays.length > 0
|
|
) {
|
|
const s1Day = s1Start.getDay();
|
|
const s1Adjusted = s1Day === 0 ? 0 : s1Day;
|
|
return schedule2Weekdays.includes(s1Adjusted);
|
|
}
|
|
|
|
// Both have weekdays: check if any day overlaps
|
|
if (schedule1Weekdays && schedule2Weekdays) {
|
|
return schedule1Weekdays.some((d) => schedule2Weekdays.includes(d));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if a driver has conflicts with existing schedules
|
|
*/
|
|
function checkDriverConflict(
|
|
driverId,
|
|
startTime,
|
|
endTime,
|
|
weekdays,
|
|
allSchedules,
|
|
excludeScheduleId = null,
|
|
) {
|
|
if (!driverId) return false; // No conflict if no driver specified
|
|
|
|
return allSchedules.some((s) => {
|
|
if (excludeScheduleId && s.id === excludeScheduleId) return false;
|
|
const sDriverId = s.driverId ?? s.driver_id;
|
|
if (Number(sDriverId) !== Number(driverId)) return false;
|
|
|
|
const sWeekdays = parseWeekdaysValue(s.weekdays);
|
|
const wkdays = weekdays || [];
|
|
const sStart = s.startTime ?? s.start_time;
|
|
const sEnd = s.endTime ?? s.end_time;
|
|
|
|
return doSchedulesDateOverlap(
|
|
startTime,
|
|
endTime,
|
|
wkdays,
|
|
sStart,
|
|
sEnd,
|
|
sWeekdays,
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if a bus has conflicts with existing schedules
|
|
*/
|
|
function checkBusConflict(
|
|
busId,
|
|
startTime,
|
|
endTime,
|
|
weekdays,
|
|
allSchedules,
|
|
excludeScheduleId = null,
|
|
) {
|
|
if (!busId) return false;
|
|
|
|
return allSchedules.some((s) => {
|
|
if (excludeScheduleId && s.id === excludeScheduleId) return false;
|
|
const sBusId = s.busId ?? s.bus_id;
|
|
if (Number(sBusId) !== Number(busId)) return false;
|
|
|
|
const sWeekdays = parseWeekdaysValue(s.weekdays);
|
|
const wkdays = weekdays || [];
|
|
const sStart = s.startTime ?? s.start_time;
|
|
const sEnd = s.endTime ?? s.end_time;
|
|
|
|
return doSchedulesDateOverlap(
|
|
startTime,
|
|
endTime,
|
|
wkdays,
|
|
sStart,
|
|
sEnd,
|
|
sWeekdays,
|
|
);
|
|
});
|
|
}
|
|
|
|
async function ensureScheduleStopProgressTable() {
|
|
if (DEV_MOCK) return;
|
|
|
|
await db.query(`
|
|
CREATE TABLE IF NOT EXISTS transit.schedule_stop_progress (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
schedule_id INTEGER NOT NULL,
|
|
bus_line_id INTEGER NOT NULL,
|
|
stop_id INTEGER NOT NULL,
|
|
stop_order INTEGER NOT NULL,
|
|
stop_name TEXT,
|
|
estimated_arrival_minutes INTEGER NOT NULL DEFAULT 0,
|
|
scheduled_passed_at TIMESTAMPTZ NOT NULL,
|
|
scheduled_passed_date DATE NOT NULL,
|
|
actual_passed_at TIMESTAMPTZ NOT NULL,
|
|
delay_minutes INTEGER NOT NULL DEFAULT 0,
|
|
marked_by_user_id INTEGER,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (schedule_id, stop_order, scheduled_passed_date)
|
|
)
|
|
`);
|
|
|
|
await db.query(`
|
|
ALTER TABLE transit.schedule_stop_progress
|
|
ADD COLUMN IF NOT EXISTS scheduled_passed_date DATE
|
|
`);
|
|
|
|
await db.query(`
|
|
UPDATE transit.schedule_stop_progress
|
|
SET scheduled_passed_date = scheduled_passed_at::date
|
|
WHERE scheduled_passed_date IS NULL
|
|
`);
|
|
|
|
await db.query(`
|
|
ALTER TABLE transit.schedule_stop_progress
|
|
ALTER COLUMN scheduled_passed_date SET NOT NULL
|
|
`);
|
|
|
|
await db.query(`
|
|
DO $$
|
|
DECLARE
|
|
constraint_name text;
|
|
BEGIN
|
|
SELECT tc.constraint_name
|
|
INTO constraint_name
|
|
FROM information_schema.table_constraints tc
|
|
JOIN information_schema.key_column_usage kcu
|
|
ON tc.constraint_name = kcu.constraint_name
|
|
AND tc.table_schema = kcu.table_schema
|
|
AND tc.table_name = kcu.table_name
|
|
WHERE tc.table_schema = 'transit'
|
|
AND tc.table_name = 'schedule_stop_progress'
|
|
AND tc.constraint_type = 'UNIQUE'
|
|
GROUP BY tc.constraint_name
|
|
HAVING array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text[] = ARRAY['schedule_id', 'stop_order']::text[]
|
|
LIMIT 1;
|
|
|
|
IF constraint_name IS NOT NULL THEN
|
|
EXECUTE format('ALTER TABLE transit.schedule_stop_progress DROP CONSTRAINT %I', constraint_name);
|
|
END IF;
|
|
END $$;
|
|
`);
|
|
|
|
await db.query(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_schedule_stop_progress_schedule_order_date
|
|
ON transit.schedule_stop_progress (schedule_id, stop_order, scheduled_passed_date)
|
|
`);
|
|
|
|
await db.query(`
|
|
CREATE INDEX IF NOT EXISTS idx_schedule_stop_progress_schedule_order
|
|
ON transit.schedule_stop_progress (schedule_id, stop_order, scheduled_passed_date)
|
|
`);
|
|
}
|
|
|
|
// ============================================
|
|
// ROUTES - Get daily schedule
|
|
// ============================================
|
|
app.get("/schedule/daily", authMiddleware, async (req, res) => {
|
|
try {
|
|
const { date } = req.query;
|
|
const userId = req.user.userId;
|
|
|
|
if (!date) {
|
|
return res.status(400).json({ error: "date query parameter required" });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
r.start_time,
|
|
r.end_time,
|
|
r.status,
|
|
bl.id as line_id,
|
|
bl.line_number,
|
|
bl.start_stop,
|
|
bl.end_stop,
|
|
bl.estimated_duration_minutes,
|
|
bl.description,
|
|
bt.id as bus_type_id,
|
|
bt.name as bus_type,
|
|
bt.seat_capacity,
|
|
bt.license_plate
|
|
FROM transit.routes r
|
|
JOIN transit.bus_lines bl ON r.bus_line_id = bl.id
|
|
JOIN fleet.bus bt ON r.bus_type_id = bt.id
|
|
WHERE r.user_id = $1 AND DATE(r.start_time) = $2
|
|
ORDER BY r.start_time ASC
|
|
`,
|
|
[userId, date],
|
|
);
|
|
|
|
let routes = result.rows.map((row) => ({
|
|
id: row.id,
|
|
startTime: row.start_time,
|
|
endTime: row.end_time,
|
|
status: row.status,
|
|
busLine: {
|
|
id: row.line_id,
|
|
lineNumber: row.line_number,
|
|
startStop: row.start_stop,
|
|
endStop: row.end_stop,
|
|
estimatedDuration: row.estimated_duration_minutes,
|
|
description: row.description,
|
|
},
|
|
busType: {
|
|
id: row.bus_type_id,
|
|
name: row.bus_type,
|
|
seatCapacity: row.seat_capacity,
|
|
licensePlate: row.license_plate,
|
|
},
|
|
}));
|
|
|
|
// If no explicit "routes" rows exist for this user/date, fall back to
|
|
// checking scheduled runs in `transit.schedules` (filtered by driver_id).
|
|
// This works around cases where DB permissions prevent inserting into
|
|
// `transit.routes` but schedules exist and should be shown to the driver.
|
|
if (!DEV_MOCK && routes.length === 0) {
|
|
const fallback = await db.query(
|
|
`
|
|
SELECT
|
|
s.id,
|
|
s.start_time,
|
|
s.end_time,
|
|
s.status,
|
|
s.bus_line_id,
|
|
bl.line_number,
|
|
bl.start_stop,
|
|
bl.end_stop,
|
|
bl.estimated_duration_minutes,
|
|
bl.description,
|
|
s.bus_id,
|
|
bt.id as bus_type_id,
|
|
bt.name as bus_type,
|
|
bt.seat_capacity,
|
|
bt.license_plate
|
|
FROM transit.schedules s
|
|
JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
|
|
JOIN fleet.bus bt ON s.bus_id = bt.id
|
|
WHERE s.driver_id = $1 AND DATE(s.start_time) = $2
|
|
ORDER BY s.start_time ASC
|
|
`,
|
|
[userId, date],
|
|
);
|
|
|
|
routes = fallback.rows.map((row) => ({
|
|
id: row.id,
|
|
startTime: row.start_time,
|
|
endTime: row.end_time,
|
|
status: row.status,
|
|
busLine: {
|
|
id: row.bus_line_id,
|
|
lineNumber: row.line_number,
|
|
startStop: row.start_stop,
|
|
endStop: row.end_stop,
|
|
estimatedDuration: row.estimated_duration_minutes,
|
|
description: row.description,
|
|
},
|
|
busType: {
|
|
id: row.bus_type_id,
|
|
name: row.bus_type,
|
|
seatCapacity: row.seat_capacity,
|
|
licensePlate: row.license_plate,
|
|
},
|
|
}));
|
|
}
|
|
|
|
// If still empty, try to synthesize a single route by shifting a recent
|
|
// schedule's time-of-day to the requested date. This is a non-destructive
|
|
// fallback to make sure drivers see a trip when DB inserts are restricted.
|
|
if (!DEV_MOCK && routes.length === 0) {
|
|
const tmplRes = await db.query(
|
|
`
|
|
SELECT s.start_time, s.end_time, s.status, s.bus_line_id, s.bus_id, s.driver_id, bl.estimated_duration_minutes, bl.line_number, bl.start_stop, bl.end_stop, bl.description
|
|
FROM transit.schedules s
|
|
JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
|
|
WHERE s.driver_id = $1
|
|
ORDER BY s.start_time DESC
|
|
LIMIT 1
|
|
`,
|
|
[userId],
|
|
);
|
|
|
|
if (tmplRes.rowCount > 0) {
|
|
const t = tmplRes.rows[0];
|
|
const requestedDate = new Date(date + "T00:00:00.000Z");
|
|
const srcStart = new Date(t.start_time);
|
|
const srcEnd = new Date(t.end_time);
|
|
|
|
// Preserve time-of-day from template, apply to requested date
|
|
const newStart = new Date(requestedDate);
|
|
newStart.setUTCHours(
|
|
srcStart.getUTCHours(),
|
|
srcStart.getUTCMinutes(),
|
|
srcStart.getUTCSeconds(),
|
|
srcStart.getUTCMilliseconds(),
|
|
);
|
|
const durationMs = srcEnd.getTime() - srcStart.getTime();
|
|
const newEnd = new Date(newStart.getTime() + durationMs);
|
|
|
|
routes.push({
|
|
id: null,
|
|
startTime: newStart.toISOString(),
|
|
endTime: newEnd.toISOString(),
|
|
status: t.status || "planned",
|
|
busLine: {
|
|
id: t.bus_line_id,
|
|
lineNumber: t.line_number,
|
|
startStop: t.start_stop,
|
|
endStop: t.end_stop,
|
|
estimatedDuration: t.estimated_duration_minutes,
|
|
description: t.description,
|
|
},
|
|
busType: {
|
|
id: t.bus_id,
|
|
name: null,
|
|
seatCapacity: null,
|
|
licensePlate: null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({ date, routes });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// DRIVER - Weekly schedule templates assigned through transit.shifts.
|
|
// day_of_week uses JavaScript/PostgreSQL convention: 0 Sunday, 1 Monday ... 6 Saturday.
|
|
app.get("/driver/schedules/daily", authMiddleware, async (req, res) => {
|
|
try {
|
|
const { date } = req.query;
|
|
if (!isValidDateKey(date)) {
|
|
return res.status(400).json({ error: "date must use YYYY-MM-DD format" });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
SELECT s.id, s.bus_line_id, s.start_time, s.end_time, s.status, s.day_of_week,
|
|
bl.line_number, bl.start_stop, bl.end_stop, bl.estimated_duration_minutes,
|
|
bl.description
|
|
FROM transit.schedules s
|
|
JOIN transit.bus_lines bl ON bl.id = s.bus_line_id
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM transit.shifts sh
|
|
WHERE sh.schedule_id = s.id AND sh.driver_id = $1
|
|
)
|
|
AND s.day_of_week = EXTRACT(DOW FROM $2::date)::int
|
|
AND s.status <> 'cancelled'
|
|
ORDER BY s.start_time ASC
|
|
`,
|
|
[req.user.userId, date],
|
|
);
|
|
|
|
res.json({
|
|
date,
|
|
routes: result.rows.map((row) => ({
|
|
id: Number(row.id),
|
|
startTime: row.start_time,
|
|
endTime: row.end_time,
|
|
status: row.status,
|
|
dayOfWeek: Number(row.day_of_week),
|
|
busLine: {
|
|
id: Number(row.bus_line_id),
|
|
lineNumber: row.line_number,
|
|
startStop: row.start_stop,
|
|
endStop: row.end_stop,
|
|
estimatedDuration: row.estimated_duration_minutes,
|
|
description: row.description,
|
|
},
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error("GET /driver/schedules/daily error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// ROUTES - Get single route details
|
|
// ============================================
|
|
app.get("/routes/:routeId", authMiddleware, async (req, res) => {
|
|
try {
|
|
const { routeId } = req.params;
|
|
const userId = req.user.userId;
|
|
|
|
const result = await db.query(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
r.start_time,
|
|
r.end_time,
|
|
r.status,
|
|
bl.id as line_id,
|
|
bl.line_number,
|
|
bl.start_stop,
|
|
bl.end_stop,
|
|
bl.estimated_duration_minutes,
|
|
bl.description,
|
|
bt.id as bus_type_id,
|
|
bt.name as bus_type,
|
|
bt.seat_capacity,
|
|
bt.license_plate
|
|
FROM routes r
|
|
JOIN bus_lines bl ON r.bus_line_id = bl.id
|
|
JOIN bus_types bt ON r.bus_type_id = bt.id
|
|
WHERE r.id = $1 AND r.user_id = $2
|
|
`,
|
|
[routeId, userId],
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: "route_not_found" });
|
|
}
|
|
|
|
const row = result.rows[0];
|
|
const route = {
|
|
id: row.id,
|
|
startTime: row.start_time,
|
|
endTime: row.end_time,
|
|
status: row.status,
|
|
busLine: {
|
|
id: row.line_id,
|
|
lineNumber: row.line_number,
|
|
startStop: row.start_stop,
|
|
endStop: row.end_stop,
|
|
estimatedDuration: row.estimated_duration_minutes,
|
|
description: row.description,
|
|
},
|
|
busType: {
|
|
id: row.bus_type_id,
|
|
name: row.bus_type,
|
|
seatCapacity: row.seat_capacity,
|
|
licensePlate: row.license_plate,
|
|
},
|
|
};
|
|
|
|
res.json({ route });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// ROUTES - Get stops for a bus line
|
|
// ============================================
|
|
app.get("/bus-lines/:busLineId/stops", authMiddleware, async (req, res) => {
|
|
try {
|
|
const { busLineId } = req.params;
|
|
if (DEV_MOCK) {
|
|
const stops = _mock.routeStops
|
|
.filter((rs) => String(rs.busLineId) === String(busLineId))
|
|
.sort((a, b) => a.stopOrder - b.stopOrder)
|
|
.map((rs) => ({
|
|
id: rs.id,
|
|
order: rs.stopOrder,
|
|
name: rs.name,
|
|
latitude: rs.latitude,
|
|
longitude: rs.longitude,
|
|
estimatedArrivalMinutes: rs.estimatedArrivalMinutes,
|
|
}));
|
|
return res.json({ stops });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
SELECT
|
|
rs.id,
|
|
rs.stop_order,
|
|
s.name as stop_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],
|
|
);
|
|
|
|
const stops = result.rows.map((row) => ({
|
|
id: row.id,
|
|
order: row.stop_order,
|
|
name: row.stop_name,
|
|
latitude: parseFloat(row.latitude),
|
|
longitude: parseFloat(row.longitude),
|
|
estimatedArrivalMinutes: row.estimated_arrival_minutes,
|
|
}));
|
|
|
|
res.json({ stops });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// ADMIN - Create reusable stop
|
|
// ============================================
|
|
app.get("/admin/stops", authMiddleware, adminMiddleware, async (req, res) => {
|
|
try {
|
|
if (DEV_MOCK) {
|
|
return res.json({
|
|
stops: _mock.stops.slice().sort((a, b) => a.id - b.id),
|
|
});
|
|
}
|
|
|
|
const result = await db.query(`
|
|
SELECT id, name, latitude, longitude, created_at, updated_at
|
|
FROM transit.stops
|
|
ORDER BY id ASC
|
|
`);
|
|
|
|
res.json({
|
|
stops: result.rows.map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
latitude: parseFloat(row.latitude),
|
|
longitude: parseFloat(row.longitude),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
app.post("/admin/stops", authMiddleware, adminMiddleware, async (req, res) => {
|
|
try {
|
|
const { name, latitude, longitude } = req.body;
|
|
|
|
if (!name || latitude === undefined || longitude === undefined) {
|
|
return res.status(400).json({ error: "missing_required_fields" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const stop = {
|
|
id: _mock.nextStopId++,
|
|
name,
|
|
latitude: parseFloat(latitude),
|
|
longitude: parseFloat(longitude),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
_mock.stops.push(stop);
|
|
return res.status(201).json({ stop });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
WITH next_id AS (
|
|
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.stops
|
|
)
|
|
INSERT INTO transit.stops (id, name, latitude, longitude)
|
|
SELECT next_id.id, $1, $2, $3
|
|
FROM next_id
|
|
RETURNING id, name, latitude, longitude, created_at, updated_at
|
|
`,
|
|
[name, latitude, longitude],
|
|
);
|
|
|
|
const stop = result.rows[0];
|
|
res.status(201).json({
|
|
stop: {
|
|
id: stop.id,
|
|
name: stop.name,
|
|
latitude: parseFloat(stop.latitude),
|
|
longitude: parseFloat(stop.longitude),
|
|
createdAt: stop.created_at,
|
|
updatedAt: stop.updated_at,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// ADMIN - Manage bus lines
|
|
// ============================================
|
|
app.get(
|
|
"/admin/bus-lines",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
if (DEV_MOCK) {
|
|
return res.json({
|
|
busLines: _mock.busLines
|
|
.slice()
|
|
.sort((a, b) => a.lineNumber - b.lineNumber),
|
|
});
|
|
}
|
|
|
|
const result = await db.query(`
|
|
SELECT id, line_number, start_stop, end_stop, estimated_duration_minutes, description
|
|
FROM transit.bus_lines
|
|
ORDER BY line_number ASC, id ASC
|
|
`);
|
|
|
|
res.json({
|
|
busLines: result.rows.map((row) => ({
|
|
id: row.id,
|
|
lineNumber: row.line_number,
|
|
startStop: row.start_stop,
|
|
endStop: row.end_stop,
|
|
estimatedDurationMinutes: row.estimated_duration_minutes,
|
|
description: row.description,
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/admin/bus-lines",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const {
|
|
lineNumber,
|
|
startStop,
|
|
endStop,
|
|
estimatedDurationMinutes,
|
|
description,
|
|
} = req.body;
|
|
|
|
if (
|
|
lineNumber === undefined ||
|
|
!startStop ||
|
|
!endStop ||
|
|
estimatedDurationMinutes === undefined
|
|
) {
|
|
return res.status(400).json({ error: "missing_required_fields" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const busLine = {
|
|
id: _mock.nextBusLineId++,
|
|
lineNumber: Number(lineNumber),
|
|
startStop,
|
|
endStop,
|
|
estimatedDurationMinutes: Number(estimatedDurationMinutes),
|
|
description: description || "",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
_mock.busLines.push(busLine);
|
|
return res.status(201).json({ busLine });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
WITH next_id AS (
|
|
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.bus_lines
|
|
)
|
|
INSERT INTO transit.bus_lines (id, line_number, start_stop, end_stop, estimated_duration_minutes, description)
|
|
SELECT next_id.id, $1, $2, $3, $4, $5
|
|
FROM next_id
|
|
RETURNING id, line_number, start_stop, end_stop, estimated_duration_minutes, description
|
|
`,
|
|
[
|
|
lineNumber,
|
|
startStop,
|
|
endStop,
|
|
estimatedDurationMinutes,
|
|
description || null,
|
|
],
|
|
);
|
|
|
|
const busLine = result.rows[0];
|
|
res.status(201).json({
|
|
busLine: {
|
|
id: busLine.id,
|
|
lineNumber: busLine.line_number,
|
|
startStop: busLine.start_stop,
|
|
endStop: busLine.end_stop,
|
|
estimatedDurationMinutes: busLine.estimated_duration_minutes,
|
|
description: busLine.description,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.patch(
|
|
"/admin/bus-lines/:busLineId",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { busLineId } = req.params;
|
|
const {
|
|
lineNumber,
|
|
startStop,
|
|
endStop,
|
|
estimatedDurationMinutes,
|
|
description,
|
|
} = req.body;
|
|
|
|
if (DEV_MOCK) {
|
|
const existing = _mock.busLines.find(
|
|
(line) => String(line.id) === String(busLineId),
|
|
);
|
|
if (!existing)
|
|
return res.status(404).json({ error: "bus_line_not_found" });
|
|
if (lineNumber !== undefined) existing.lineNumber = Number(lineNumber);
|
|
if (startStop !== undefined) existing.startStop = startStop;
|
|
if (endStop !== undefined) existing.endStop = endStop;
|
|
if (estimatedDurationMinutes !== undefined)
|
|
existing.estimatedDurationMinutes = Number(estimatedDurationMinutes);
|
|
if (description !== undefined) existing.description = description;
|
|
existing.updatedAt = new Date().toISOString();
|
|
return res.json({ busLine: existing });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
UPDATE transit.bus_lines
|
|
SET
|
|
line_number = COALESCE($1, line_number),
|
|
start_stop = COALESCE($2, start_stop),
|
|
end_stop = COALESCE($3, end_stop),
|
|
estimated_duration_minutes = COALESCE($4, estimated_duration_minutes),
|
|
description = COALESCE($5, description)
|
|
WHERE id = $6
|
|
RETURNING id, line_number, start_stop, end_stop, estimated_duration_minutes, description
|
|
`,
|
|
[
|
|
lineNumber ?? null,
|
|
startStop ?? null,
|
|
endStop ?? null,
|
|
estimatedDurationMinutes ?? null,
|
|
description ?? null,
|
|
busLineId,
|
|
],
|
|
);
|
|
|
|
if (!result.rows[0])
|
|
return res.status(404).json({ error: "bus_line_not_found" });
|
|
|
|
const busLine = result.rows[0];
|
|
res.json({
|
|
busLine: {
|
|
id: busLine.id,
|
|
lineNumber: busLine.line_number,
|
|
startStop: busLine.start_stop,
|
|
endStop: busLine.end_stop,
|
|
estimatedDurationMinutes: busLine.estimated_duration_minutes,
|
|
description: busLine.description,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
// ============================================
|
|
// ADMIN - Manage fleet buses
|
|
// ============================================
|
|
app.get(
|
|
"/admin/fleet/buses",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
if (DEV_MOCK) {
|
|
const buses = _mock.fleetBuses.slice().sort((a, b) => a.id - b.id);
|
|
return res.json({
|
|
buses: buses.map((b) => ({
|
|
...b,
|
|
available: !checkBusConflict(
|
|
b.id,
|
|
new Date().toISOString(),
|
|
new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
[],
|
|
_mock.schedules || [],
|
|
),
|
|
})),
|
|
});
|
|
}
|
|
|
|
const result = await db.query(`
|
|
SELECT id, name, seat_capacity, license_plate
|
|
FROM fleet.bus
|
|
ORDER BY id ASC
|
|
`);
|
|
|
|
// Get all schedules to check for conflicts
|
|
const schedulesResult = await db.query(
|
|
`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`,
|
|
);
|
|
const schedules = schedulesResult.rows.map((row) => ({
|
|
...row,
|
|
weekdays: parseWeekdaysValue(row.weekdays),
|
|
}));
|
|
|
|
res.json({
|
|
buses: result.rows.map((row) => {
|
|
const busId = row.id;
|
|
const now = new Date();
|
|
const tomorrow = new Date(now);
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
const isAvailable = !checkBusConflict(
|
|
busId,
|
|
now.toISOString(),
|
|
tomorrow.toISOString(),
|
|
[],
|
|
schedules,
|
|
);
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
seatCapacity: row.seat_capacity,
|
|
licensePlate: row.license_plate,
|
|
available: isAvailable,
|
|
};
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/admin/fleet/buses",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { name, seatCapacity, licensePlate } = req.body;
|
|
|
|
if (!name || seatCapacity === undefined || !licensePlate) {
|
|
return res.status(400).json({ error: "missing_required_fields" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const bus = {
|
|
id: _mock.nextFleetBusId++,
|
|
name,
|
|
seatCapacity: Number(seatCapacity),
|
|
licensePlate,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
_mock.fleetBuses.push(bus);
|
|
return res.status(201).json({ bus });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
WITH next_id AS (
|
|
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM fleet.bus
|
|
)
|
|
INSERT INTO fleet.bus (id, name, seat_capacity, license_plate)
|
|
SELECT next_id.id, $1, $2, $3
|
|
FROM next_id
|
|
RETURNING id, name, seat_capacity, license_plate
|
|
`,
|
|
[name, seatCapacity, licensePlate],
|
|
);
|
|
|
|
const bus = result.rows[0];
|
|
res.status(201).json({
|
|
bus: {
|
|
id: bus.id,
|
|
name: bus.name,
|
|
seatCapacity: bus.seat_capacity,
|
|
licensePlate: bus.license_plate,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
// ============================================
|
|
// ADMIN - Update fleet bus
|
|
// ============================================
|
|
app.patch(
|
|
"/admin/fleet/buses/:busId",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { busId } = req.params;
|
|
const { name, seatCapacity, licensePlate } = req.body;
|
|
|
|
if (!busId || !Number.isFinite(Number(busId))) {
|
|
return res.status(400).json({ error: "invalid_bus_id" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const bus = _mock.fleetBuses.find(
|
|
(b) => Number(b.id) === Number(busId),
|
|
);
|
|
if (!bus) return res.status(404).json({ error: "bus_not_found" });
|
|
if (name !== undefined) bus.name = name;
|
|
if (seatCapacity !== undefined) bus.seatCapacity = Number(seatCapacity);
|
|
if (licensePlate !== undefined) bus.licensePlate = licensePlate;
|
|
bus.updatedAt = new Date().toISOString();
|
|
return res.json({ bus });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
UPDATE fleet.bus
|
|
SET name = COALESCE($1, name), seat_capacity = COALESCE($2, seat_capacity), license_plate = COALESCE($3, license_plate), updated_at = NOW()
|
|
WHERE id = $4
|
|
RETURNING id, name, seat_capacity, license_plate
|
|
`,
|
|
[name ?? null, seatCapacity ?? null, licensePlate ?? null, busId],
|
|
);
|
|
|
|
if (!result.rows[0])
|
|
return res.status(404).json({ error: "bus_not_found" });
|
|
|
|
const bus = result.rows[0];
|
|
res.json({
|
|
bus: {
|
|
id: bus.id,
|
|
name: bus.name,
|
|
seatCapacity: bus.seat_capacity,
|
|
licensePlate: bus.license_plate,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
// ============================================
|
|
// ADMIN - Delete fleet bus
|
|
// ============================================
|
|
app.delete(
|
|
"/admin/fleet/buses/:busId",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { busId } = req.params;
|
|
if (!busId || !Number.isFinite(Number(busId))) {
|
|
return res.status(400).json({ error: "invalid_bus_id" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const idx = _mock.fleetBuses.findIndex(
|
|
(b) => Number(b.id) === Number(busId),
|
|
);
|
|
if (idx === -1) return res.status(404).json({ error: "bus_not_found" });
|
|
const deleted = _mock.fleetBuses.splice(idx, 1)[0];
|
|
return res.json({ success: true, bus: deleted });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`DELETE FROM fleet.bus WHERE id = $1 RETURNING id, name`,
|
|
[busId],
|
|
);
|
|
if (result.rows.length === 0)
|
|
return res.status(404).json({ error: "bus_not_found" });
|
|
res.json({ success: true, bus: result.rows[0] });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
// ============================================
|
|
// ADMIN - Link stop to bus line
|
|
// ============================================
|
|
app.get(
|
|
"/admin/bus-lines/:busLineId/stops",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
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 stops = _mock.routeStops
|
|
.filter((rs) => String(rs.busLineId) === String(busLineId))
|
|
.sort((a, b) => a.stopOrder - b.stopOrder)
|
|
.map((rs) => ({
|
|
id: rs.id,
|
|
busLineId: rs.busLineId,
|
|
stopId: rs.stopId,
|
|
stopOrder: rs.stopOrder,
|
|
estimatedArrivalMinutes: rs.estimatedArrivalMinutes,
|
|
name: rs.name,
|
|
latitude: rs.latitude,
|
|
longitude: rs.longitude,
|
|
}));
|
|
return res.json({ routeStops: stops });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
SELECT
|
|
rs.id,
|
|
rs.bus_line_id,
|
|
rs.stop_id,
|
|
rs.stop_order,
|
|
rs.estimated_arrival_minutes,
|
|
s.name as stop_name,
|
|
s.latitude,
|
|
s.longitude
|
|
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({
|
|
routeStops: result.rows.map((row) => ({
|
|
id: row.id,
|
|
busLineId: row.bus_line_id,
|
|
stopId: row.stop_id,
|
|
stopOrder: row.stop_order,
|
|
estimatedArrivalMinutes: row.estimated_arrival_minutes,
|
|
name: row.stop_name,
|
|
latitude: parseFloat(row.latitude),
|
|
longitude: parseFloat(row.longitude),
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/admin/bus-lines/:busLineId/stops",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { busLineId } = req.params;
|
|
const { stopId, stopOrder, estimatedArrivalMinutes } = req.body;
|
|
|
|
if (!stopId || stopOrder === undefined) {
|
|
return res.status(400).json({ error: "missing_required_fields" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const stop = _mock.stops.find((s) => String(s.id) === String(stopId));
|
|
const routeStop = {
|
|
id: _mock.nextRouteStopId++,
|
|
busLineId: Number(busLineId),
|
|
stopId: Number(stopId),
|
|
stopOrder: Number(stopOrder),
|
|
estimatedArrivalMinutes: estimatedArrivalMinutes ?? null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
name: stop ? stop.name : `stop-${stopId}`,
|
|
latitude: stop ? stop.latitude : 0,
|
|
longitude: stop ? stop.longitude : 0,
|
|
};
|
|
// remove existing with same busLineId & stopOrder
|
|
_mock.routeStops = _mock.routeStops.filter(
|
|
(rs) =>
|
|
!(
|
|
rs.busLineId === routeStop.busLineId &&
|
|
rs.stopOrder === routeStop.stopOrder
|
|
),
|
|
);
|
|
_mock.routeStops.push(routeStop);
|
|
return res.status(201).json({ routeStop });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
WITH next_id AS (
|
|
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.route_stops
|
|
)
|
|
INSERT INTO transit.route_stops (id, bus_line_id, stop_id, stop_order, estimated_arrival_minutes)
|
|
SELECT next_id.id, $1, $2, $3, $4
|
|
FROM next_id
|
|
ON CONFLICT (bus_line_id, stop_order) DO UPDATE SET
|
|
stop_id = EXCLUDED.stop_id,
|
|
estimated_arrival_minutes = EXCLUDED.estimated_arrival_minutes,
|
|
updated_at = NOW()
|
|
RETURNING id, bus_line_id, stop_id, stop_order, estimated_arrival_minutes, created_at, updated_at
|
|
`,
|
|
[busLineId, stopId, stopOrder, estimatedArrivalMinutes ?? null],
|
|
);
|
|
|
|
const link = result.rows[0];
|
|
res.status(201).json({
|
|
routeStop: {
|
|
id: link.id,
|
|
busLineId: link.bus_line_id,
|
|
stopId: link.stop_id,
|
|
stopOrder: link.stop_order,
|
|
estimatedArrivalMinutes: link.estimated_arrival_minutes,
|
|
createdAt: link.created_at,
|
|
updatedAt: link.updated_at,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/admin/bus-lines/:busLineId/stops/reorder",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
const client = await db.pool.connect();
|
|
try {
|
|
const { busLineId } = req.params;
|
|
const { routeStopIds } = req.body || {};
|
|
|
|
if (!Array.isArray(routeStopIds) || routeStopIds.length === 0) {
|
|
return res.status(400).json({ error: "routeStopIds array required" });
|
|
}
|
|
|
|
const normalizedIds = routeStopIds
|
|
.map((id) => Number(id))
|
|
.filter((id) => Number.isFinite(id));
|
|
if (normalizedIds.length !== routeStopIds.length) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "routeStopIds must contain only numeric ids" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const busLineStops = _mock.routeStops
|
|
.filter((rs) => String(rs.busLineId) === String(busLineId))
|
|
.sort((a, b) => a.stopOrder - b.stopOrder);
|
|
|
|
const idSet = new Set(busLineStops.map((rs) => Number(rs.id)));
|
|
const sameCount = busLineStops.length === normalizedIds.length;
|
|
const sameIds = sameCount && normalizedIds.every((id) => idSet.has(id));
|
|
if (!sameIds) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "routeStopIds must match current bus line stops" });
|
|
}
|
|
|
|
const updated = normalizedIds.map((routeStopId, index) => {
|
|
const stop = _mock.routeStops.find(
|
|
(rs) =>
|
|
Number(rs.id) === routeStopId &&
|
|
String(rs.busLineId) === String(busLineId),
|
|
);
|
|
if (!stop) return null;
|
|
stop.stopOrder = index + 1;
|
|
stop.updatedAt = new Date().toISOString();
|
|
return stop;
|
|
});
|
|
|
|
if (updated.some((item) => !item)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "routeStopIds must match current bus line stops" });
|
|
}
|
|
|
|
_mock.routeStops = _mock.routeStops.map((routeStop) => {
|
|
const reordered = updated.find((item) => item.id === routeStop.id);
|
|
return reordered || routeStop;
|
|
});
|
|
|
|
return res.json({
|
|
routeStops: updated,
|
|
});
|
|
}
|
|
|
|
const current = await client.query(
|
|
`SELECT id FROM transit.route_stops WHERE bus_line_id = $1 ORDER BY stop_order ASC`,
|
|
[busLineId],
|
|
);
|
|
|
|
const currentIds = current.rows.map((row) => Number(row.id));
|
|
const sameCount = currentIds.length === normalizedIds.length;
|
|
const sameIds =
|
|
sameCount && normalizedIds.every((id) => currentIds.includes(id));
|
|
|
|
if (!sameIds) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "routeStopIds must match current bus line stops" });
|
|
}
|
|
|
|
await client.query("BEGIN");
|
|
|
|
for (let index = 0; index < normalizedIds.length; index += 1) {
|
|
await client.query(
|
|
`UPDATE transit.route_stops
|
|
SET stop_order = $1, updated_at = NOW()
|
|
WHERE id = $2 AND bus_line_id = $3`,
|
|
[-(index + 1), normalizedIds[index], busLineId],
|
|
);
|
|
}
|
|
|
|
for (let index = 0; index < normalizedIds.length; index += 1) {
|
|
await client.query(
|
|
`UPDATE transit.route_stops
|
|
SET stop_order = $1, updated_at = NOW()
|
|
WHERE id = $2 AND bus_line_id = $3`,
|
|
[index + 1, normalizedIds[index], busLineId],
|
|
);
|
|
}
|
|
|
|
const result = await client.query(
|
|
`SELECT
|
|
rs.id,
|
|
rs.stop_order,
|
|
s.name as stop_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],
|
|
);
|
|
|
|
await client.query("COMMIT");
|
|
|
|
res.json({
|
|
routeStops: result.rows.map((row) => ({
|
|
id: row.id,
|
|
order: row.stop_order,
|
|
name: row.stop_name,
|
|
latitude: parseFloat(row.latitude),
|
|
longitude: parseFloat(row.longitude),
|
|
estimatedArrivalMinutes: row.estimated_arrival_minutes,
|
|
})),
|
|
});
|
|
} 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 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 - Update a stop
|
|
// ============================================
|
|
app.patch(
|
|
"/admin/stops/:stopId",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { stopId } = req.params;
|
|
const { name, latitude, longitude } = req.body;
|
|
|
|
if (!stopId || !Number.isFinite(Number(stopId))) {
|
|
return res.status(400).json({ error: "invalid_stop_id" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const stop = _mock.stops.find((s) => Number(s.id) === Number(stopId));
|
|
if (!stop) return res.status(404).json({ error: "stop_not_found" });
|
|
if (name !== undefined) stop.name = name;
|
|
if (latitude !== undefined) stop.latitude = parseFloat(latitude);
|
|
if (longitude !== undefined) stop.longitude = parseFloat(longitude);
|
|
stop.updatedAt = new Date().toISOString();
|
|
return res.json({ stop });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`UPDATE transit.stops
|
|
SET name = COALESCE($1, name),
|
|
latitude = COALESCE($2, latitude),
|
|
longitude = COALESCE($3, longitude),
|
|
updated_at = NOW()
|
|
WHERE id = $4
|
|
RETURNING id, name, latitude, longitude, created_at, updated_at`,
|
|
[name ?? null, latitude ?? null, longitude ?? null, stopId],
|
|
);
|
|
|
|
if (!result.rows[0])
|
|
return res.status(404).json({ error: "stop_not_found" });
|
|
|
|
const stop = result.rows[0];
|
|
res.json({
|
|
stop: {
|
|
id: stop.id,
|
|
name: stop.name,
|
|
latitude: parseFloat(stop.latitude),
|
|
longitude: parseFloat(stop.longitude),
|
|
createdAt: stop.created_at,
|
|
updatedAt: stop.updated_at,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
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
|
|
// ============================================
|
|
app.post("/navigation/route", authMiddleware, async (req, res) => {
|
|
try {
|
|
const {
|
|
routeId,
|
|
startLat,
|
|
startLon,
|
|
endLat,
|
|
endLon,
|
|
totalDistance,
|
|
totalDuration,
|
|
geojson,
|
|
} = req.body;
|
|
|
|
if (
|
|
!routeId ||
|
|
!startLat ||
|
|
!startLon ||
|
|
!endLat ||
|
|
!endLon ||
|
|
!totalDistance ||
|
|
!totalDuration
|
|
) {
|
|
return res.status(400).json({ error: "missing_required_fields" });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
INSERT INTO navigation_routes
|
|
(route_id, start_lat, start_lon, end_lat, end_lon,
|
|
total_distance_meters, total_duration_seconds, polyline_geojson)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (route_id) DO UPDATE SET
|
|
start_lat = $2,
|
|
start_lon = $3,
|
|
end_lat = $4,
|
|
end_lon = $5,
|
|
total_distance_meters = $6,
|
|
total_duration_seconds = $7,
|
|
polyline_geojson = $8
|
|
RETURNING *
|
|
`,
|
|
[
|
|
routeId,
|
|
startLat,
|
|
startLon,
|
|
endLat,
|
|
endLon,
|
|
totalDistance,
|
|
totalDuration,
|
|
JSON.stringify(geojson),
|
|
],
|
|
);
|
|
|
|
res.json({ route: result.rows[0] });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// GPS TRACKING - Record current position
|
|
// ============================================
|
|
app.post("/gps/track", authMiddleware, async (req, res) => {
|
|
try {
|
|
const { routeId, latitude, longitude, speed, heading, accuracy } = req.body;
|
|
|
|
if (!routeId || !latitude || !longitude) {
|
|
return res.status(400).json({ error: "missing_required_fields" });
|
|
}
|
|
|
|
await db.query(
|
|
`
|
|
INSERT INTO gps_tracking
|
|
(route_id, latitude, longitude, speed_kmh, heading, accuracy, recorded_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
|
`,
|
|
[
|
|
routeId,
|
|
latitude,
|
|
longitude,
|
|
speed || null,
|
|
heading || null,
|
|
accuracy || null,
|
|
],
|
|
);
|
|
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// GPS TRACKING - Get route history
|
|
// ============================================
|
|
app.get("/gps/route/:routeId", authMiddleware, async (req, res) => {
|
|
try {
|
|
const { routeId } = req.params;
|
|
const userId = req.user.userId;
|
|
|
|
// Verify user owns this route
|
|
const routeCheck = await db.query(
|
|
"SELECT id FROM routes WHERE id = $1 AND user_id = $2",
|
|
[routeId, userId],
|
|
);
|
|
|
|
if (routeCheck.rows.length === 0) {
|
|
return res.status(403).json({ error: "unauthorized" });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
SELECT
|
|
id,
|
|
latitude,
|
|
longitude,
|
|
speed_kmh,
|
|
heading,
|
|
accuracy,
|
|
recorded_at
|
|
FROM gps_tracking
|
|
WHERE route_id = $1
|
|
ORDER BY recorded_at ASC
|
|
`,
|
|
[routeId],
|
|
);
|
|
|
|
const positions = result.rows.map((row) => ({
|
|
id: row.id,
|
|
latitude: parseFloat(row.latitude),
|
|
longitude: parseFloat(row.longitude),
|
|
speed: row.speed_kmh,
|
|
heading: row.heading,
|
|
accuracy: row.accuracy,
|
|
recordedAt: row.recorded_at,
|
|
}));
|
|
|
|
res.json({ positions });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// ROUTES - Update route status
|
|
// ============================================
|
|
app.patch("/routes/:routeId/status", authMiddleware, async (req, res) => {
|
|
try {
|
|
const { routeId } = req.params;
|
|
const { status } = req.body;
|
|
const userId = req.user.userId;
|
|
|
|
const validStatuses = [
|
|
"scheduled",
|
|
"in_progress",
|
|
"completed",
|
|
"cancelled",
|
|
];
|
|
if (!validStatuses.includes(status)) {
|
|
return res.status(400).json({ error: "invalid_status" });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
UPDATE transit.routes
|
|
SET status = $1, updated_at = NOW()
|
|
WHERE id = $2 AND user_id = $3
|
|
RETURNING *
|
|
`,
|
|
[status, routeId, userId],
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: "route_not_found" });
|
|
}
|
|
|
|
res.json({ route: result.rows[0] });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// DRIVER - Update schedule status
|
|
// ============================================
|
|
app.patch(
|
|
"/driver/weekly-schedules/:scheduleId/status",
|
|
authMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const scheduleId = Number(req.params.scheduleId);
|
|
const { status } = req.body;
|
|
const validStatuses = [
|
|
"planned",
|
|
"in_progress",
|
|
"completed",
|
|
"cancelled",
|
|
"active",
|
|
];
|
|
if (!Number.isInteger(scheduleId) || !validStatuses.includes(status)) {
|
|
return res.status(400).json({ error: "invalid_status" });
|
|
}
|
|
|
|
const normalizedStatus = status === "active" ? "in_progress" : status;
|
|
const result = await db.query(
|
|
`
|
|
UPDATE transit.schedules s
|
|
SET status = $1, updated_at = NOW()
|
|
WHERE s.id = $2
|
|
AND EXISTS (
|
|
SELECT 1 FROM transit.shifts sh
|
|
WHERE sh.schedule_id = s.id AND sh.driver_id = $3
|
|
)
|
|
RETURNING s.id, s.status
|
|
`,
|
|
[normalizedStatus, scheduleId, req.user.userId],
|
|
);
|
|
|
|
if (result.rows.length === 0)
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
res.json({
|
|
schedule: {
|
|
id: Number(result.rows[0].id),
|
|
status: result.rows[0].status,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("PATCH /driver/weekly-schedules/:id/status error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.patch(
|
|
"/driver/schedules/:scheduleId/status",
|
|
authMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { scheduleId } = req.params;
|
|
const { status } = req.body;
|
|
|
|
const validStatuses = [
|
|
"planned",
|
|
"in_progress",
|
|
"completed",
|
|
"cancelled",
|
|
"active",
|
|
];
|
|
if (!validStatuses.includes(status)) {
|
|
return res.status(400).json({ error: "invalid_status" });
|
|
}
|
|
|
|
const normalizedStatus = status === "active" ? "in_progress" : status;
|
|
|
|
// Fetch the target schedule row to detect recurring templates
|
|
const existing = await db.query(
|
|
`SELECT * FROM transit.schedules WHERE id = $1`,
|
|
[scheduleId],
|
|
);
|
|
if (existing.rows.length === 0) {
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
}
|
|
|
|
const scheduleRow = existing.rows[0];
|
|
const weekdays = parseWeekdaysValue(scheduleRow.weekdays);
|
|
|
|
// Allow updating a specific occurrence of a recurring template by passing { date: 'YYYY-MM-DD' }
|
|
const requestedDate = req.body.date || null; // optional date key
|
|
|
|
if (weekdays && weekdays.length > 0) {
|
|
// This is a recurring template. If a date was provided, or the template occurs today, operate on an instance.
|
|
let targetDateKey = requestedDate;
|
|
if (!targetDateKey) {
|
|
const todayKey = new Date().toISOString().slice(0, 10);
|
|
if (scheduleOccursOnDate(scheduleRow, todayKey)) {
|
|
targetDateKey = todayKey;
|
|
}
|
|
}
|
|
|
|
if (targetDateKey) {
|
|
// Build occurrence start/end based on template times
|
|
const templateStart = new Date(
|
|
scheduleRow.start_time ?? scheduleRow.startTime,
|
|
);
|
|
const templateEnd = new Date(
|
|
scheduleRow.end_time ?? scheduleRow.endTime,
|
|
);
|
|
const occurrenceStart = new Date(`${targetDateKey}T00:00:00`);
|
|
occurrenceStart.setHours(
|
|
templateStart.getHours(),
|
|
templateStart.getMinutes(),
|
|
templateStart.getSeconds(),
|
|
templateStart.getMilliseconds(),
|
|
);
|
|
|
|
let occurrenceEnd = null;
|
|
if (
|
|
Number.isFinite(templateEnd.getTime()) &&
|
|
Number.isFinite(templateStart.getTime())
|
|
) {
|
|
const duration = templateEnd.getTime() - templateStart.getTime();
|
|
occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
|
|
}
|
|
|
|
// Check if a concrete instance already exists for this template/date
|
|
const existingInstance = await db.query(
|
|
`SELECT * FROM transit.schedules WHERE template_id = $1 AND DATE(start_time) = $2`,
|
|
[scheduleId, targetDateKey],
|
|
);
|
|
|
|
if (existingInstance.rows.length > 0) {
|
|
const upd = await db.query(
|
|
`
|
|
UPDATE transit.schedules SET status = $1, updated_at = NOW()
|
|
WHERE id = $2
|
|
RETURNING *
|
|
`,
|
|
[normalizedStatus, existingInstance.rows[0].id],
|
|
);
|
|
return res.json({ schedule: upd.rows[0] });
|
|
}
|
|
|
|
// Insert a new instance derived from template
|
|
const hasTemplateIdCol = await db.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema='transit' AND table_name='schedules' AND column_name='template_id'
|
|
`);
|
|
|
|
let insertResult;
|
|
if (hasTemplateIdCol.rows.length > 0) {
|
|
insertResult = 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, template_id)
|
|
SELECT next_id.id, $1, $2, $3, $4, $5, $6, $7
|
|
FROM next_id
|
|
RETURNING *
|
|
`,
|
|
[
|
|
scheduleRow.bus_line_id,
|
|
scheduleRow.bus_id,
|
|
scheduleRow.driver_id,
|
|
occurrenceStart.toISOString(),
|
|
occurrenceEnd ? occurrenceEnd.toISOString() : null,
|
|
normalizedStatus,
|
|
scheduleId,
|
|
],
|
|
);
|
|
} else {
|
|
insertResult = 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, $6
|
|
FROM next_id
|
|
RETURNING *
|
|
`,
|
|
[
|
|
scheduleRow.bus_line_id,
|
|
scheduleRow.bus_id,
|
|
scheduleRow.driver_id,
|
|
occurrenceStart.toISOString(),
|
|
occurrenceEnd ? occurrenceEnd.toISOString() : null,
|
|
normalizedStatus,
|
|
],
|
|
);
|
|
}
|
|
|
|
return res.json({ schedule: insertResult.rows[0] });
|
|
}
|
|
}
|
|
|
|
// Fallback: update the schedule row directly (one-time or when no occurrence date is inferred)
|
|
const result = await db.query(
|
|
`
|
|
UPDATE transit.schedules
|
|
SET status = $1, updated_at = NOW()
|
|
WHERE id = $2
|
|
RETURNING *
|
|
`,
|
|
[normalizedStatus, scheduleId],
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
}
|
|
|
|
res.json({ schedule: result.rows[0] });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
// ============================================
|
|
// ADMIN - Get drivers for scheduling
|
|
// ============================================
|
|
app.get("/admin/drivers", authMiddleware, adminMiddleware, async (req, res) => {
|
|
try {
|
|
if (DEV_MOCK) {
|
|
const drivers = _mock.drivers
|
|
.slice()
|
|
.filter((d) => d.status === "active");
|
|
return res.json({
|
|
drivers: drivers.map((d) => ({
|
|
...d,
|
|
available: !checkDriverConflict(
|
|
d.id,
|
|
new Date().toISOString(),
|
|
new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
[],
|
|
_mock.schedules || [],
|
|
),
|
|
})),
|
|
});
|
|
}
|
|
|
|
const result = await db.query(`
|
|
SELECT id, firstname, lastname, email, phone, job, joined, status
|
|
FROM workers.employees
|
|
WHERE status = 'active'
|
|
ORDER BY firstname, lastname ASC
|
|
`);
|
|
|
|
// Get all schedules to check for conflicts
|
|
const schedulesResult = await db.query(
|
|
`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`,
|
|
);
|
|
const schedules = schedulesResult.rows.map((row) => ({
|
|
...row,
|
|
weekdays: parseWeekdaysValue(row.weekdays),
|
|
}));
|
|
|
|
res.json({
|
|
drivers: result.rows.map((row) => {
|
|
const driverId = row.id;
|
|
const now = new Date();
|
|
const tomorrow = new Date(now);
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
const isAvailable = !checkDriverConflict(
|
|
driverId,
|
|
now.toISOString(),
|
|
tomorrow.toISOString(),
|
|
[],
|
|
schedules,
|
|
);
|
|
|
|
return {
|
|
id: row.id,
|
|
name: (
|
|
String(row.firstname || "") +
|
|
" " +
|
|
String(row.lastname || "")
|
|
).trim(),
|
|
email: row.email,
|
|
phone: row.phone,
|
|
job: row.job,
|
|
joined: row.joined,
|
|
status: row.status,
|
|
available: isAvailable,
|
|
};
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// ADMIN - Manage schedules (timetable)
|
|
// ============================================
|
|
app.get(
|
|
"/admin/shift-drivers",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const result = await db.query(`
|
|
SELECT id, usercode
|
|
FROM workers.users
|
|
ORDER BY usercode ASC
|
|
`);
|
|
res.json({
|
|
drivers: result.rows.map((row) => ({
|
|
id: Number(row.id),
|
|
name: String(row.usercode),
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error("GET /admin/shift-drivers error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
"/admin/weekly-schedules",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const result = await db.query(`
|
|
SELECT s.id, s.bus_line_id, s.start_time, s.end_time, s.status, s.day_of_week,
|
|
bl.line_number, bl.start_stop, bl.end_stop,
|
|
sh.id AS shift_id, sh.driver_id, u.usercode AS driver_name
|
|
FROM transit.schedules s
|
|
JOIN transit.bus_lines bl ON bl.id = s.bus_line_id
|
|
LEFT JOIN transit.shifts sh ON sh.schedule_id = s.id
|
|
LEFT JOIN workers.users u ON u.id = sh.driver_id
|
|
ORDER BY s.day_of_week ASC, s.start_time ASC, s.id ASC
|
|
`);
|
|
res.json({
|
|
schedules: result.rows.map((row) => ({
|
|
id: Number(row.id),
|
|
busLineId: Number(row.bus_line_id),
|
|
lineNumber: row.line_number,
|
|
startStop: row.start_stop,
|
|
endStop: row.end_stop,
|
|
startTime: row.start_time,
|
|
endTime: row.end_time,
|
|
status: row.status,
|
|
dayOfWeek: Number(row.day_of_week),
|
|
shiftId: row.shift_id ? Number(row.shift_id) : null,
|
|
driverId: row.driver_id ? Number(row.driver_id) : null,
|
|
driverName: row.driver_name || null,
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error("GET /admin/weekly-schedules error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/admin/weekly-schedules",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const busLineId = Number(req.body?.busLineId);
|
|
const dayOfWeek = Number(req.body?.dayOfWeek);
|
|
const startTime = String(req.body?.startTime || "");
|
|
const endTime = String(req.body?.endTime || "");
|
|
const driverId = req.body?.driverId ? Number(req.body.driverId) : null;
|
|
|
|
if (
|
|
!Number.isInteger(busLineId) ||
|
|
!Number.isInteger(dayOfWeek) ||
|
|
dayOfWeek < 0 ||
|
|
dayOfWeek > 6 ||
|
|
!startTime ||
|
|
!endTime
|
|
) {
|
|
return res.status(400).json({
|
|
error: "busLineId, dayOfWeek, startTime and endTime are required",
|
|
});
|
|
}
|
|
|
|
const created = await db.query(
|
|
`
|
|
INSERT INTO transit.schedules (bus_line_id, start_time, end_time, status, day_of_week)
|
|
VALUES ($1, $2::time, $3::time, 'planned', $4)
|
|
RETURNING id, bus_line_id, start_time, end_time, status, day_of_week
|
|
`,
|
|
[busLineId, startTime, endTime, dayOfWeek],
|
|
);
|
|
|
|
const schedule = created.rows[0];
|
|
if (driverId) {
|
|
await db.query(
|
|
`
|
|
INSERT INTO transit.shifts (schedule_id, driver_id)
|
|
VALUES ($1, $2)
|
|
`,
|
|
[schedule.id, driverId],
|
|
);
|
|
}
|
|
|
|
res.status(201).json({
|
|
schedule: {
|
|
id: Number(schedule.id),
|
|
busLineId: Number(schedule.bus_line_id),
|
|
startTime: schedule.start_time,
|
|
endTime: schedule.end_time,
|
|
status: schedule.status,
|
|
dayOfWeek: Number(schedule.day_of_week),
|
|
driverId,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("POST /admin/weekly-schedules error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/admin/weekly-schedules/bulk",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
const busLineId = Number(req.body?.busLineId);
|
|
const dayOfWeeks = Array.isArray(req.body?.dayOfWeeks)
|
|
? req.body.dayOfWeeks
|
|
.map(Number)
|
|
.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)
|
|
: [];
|
|
const startTimes = Array.isArray(req.body?.startTimes)
|
|
? [...new Set(req.body.startTimes.map(String).filter(Boolean))]
|
|
: [];
|
|
const driverId = req.body?.driverId ? Number(req.body.driverId) : null;
|
|
|
|
if (
|
|
!Number.isInteger(busLineId) ||
|
|
dayOfWeeks.length === 0 ||
|
|
startTimes.length === 0
|
|
) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "busLineId, dayOfWeeks and startTimes are required" });
|
|
}
|
|
|
|
const client = await db.pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
const line = await client.query(
|
|
"SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1",
|
|
[busLineId],
|
|
);
|
|
if (line.rows.length === 0) {
|
|
await client.query("ROLLBACK");
|
|
return res.status(404).json({ error: "bus_line_not_found" });
|
|
}
|
|
|
|
const duration = Number(line.rows[0].estimated_duration_minutes) || 60;
|
|
const created = [];
|
|
for (const dayOfWeek of dayOfWeeks) {
|
|
for (const startTime of startTimes) {
|
|
const result = await client.query(
|
|
`INSERT INTO transit.schedules
|
|
(bus_line_id, start_time, end_time, status, day_of_week)
|
|
VALUES ($1, $2::time, ($2::time + ($3::text || ' minutes')::interval)::time, 'planned', $4)
|
|
RETURNING id, bus_line_id, start_time, end_time, status, day_of_week`,
|
|
[busLineId, startTime, duration, dayOfWeek],
|
|
);
|
|
const schedule = result.rows[0];
|
|
if (driverId) {
|
|
await client.query(
|
|
"INSERT INTO transit.shifts (schedule_id, driver_id) VALUES ($1, $2)",
|
|
[schedule.id, driverId],
|
|
);
|
|
}
|
|
created.push(schedule);
|
|
}
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
res.status(201).json({
|
|
createdCount: created.length,
|
|
schedules: created.map((schedule) => ({
|
|
id: Number(schedule.id),
|
|
busLineId: Number(schedule.bus_line_id),
|
|
startTime: schedule.start_time,
|
|
endTime: schedule.end_time,
|
|
status: schedule.status,
|
|
dayOfWeek: Number(schedule.day_of_week),
|
|
driverId,
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
await client.query("ROLLBACK");
|
|
console.error("POST /admin/weekly-schedules/bulk error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
},
|
|
);
|
|
|
|
app.put(
|
|
"/admin/weekly-schedules/:scheduleId",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const scheduleId = Number(req.params.scheduleId);
|
|
const busLineId = Number(req.body?.busLineId);
|
|
const dayOfWeek = Number(req.body?.dayOfWeek);
|
|
const startTime = String(req.body?.startTime || "");
|
|
const endTime = String(req.body?.endTime || "");
|
|
if (
|
|
!Number.isInteger(scheduleId) ||
|
|
!Number.isInteger(busLineId) ||
|
|
!Number.isInteger(dayOfWeek) ||
|
|
dayOfWeek < 0 ||
|
|
dayOfWeek > 6 ||
|
|
!startTime ||
|
|
!endTime
|
|
) {
|
|
return res.status(400).json({ error: "invalid_schedule" });
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
UPDATE transit.schedules
|
|
SET bus_line_id = $2, start_time = $3::time, end_time = $4::time,
|
|
day_of_week = $5, updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, bus_line_id, start_time, end_time, status, day_of_week
|
|
`,
|
|
[scheduleId, busLineId, startTime, endTime, dayOfWeek],
|
|
);
|
|
if (result.rows.length === 0)
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
res.json({ schedule: result.rows[0] });
|
|
} catch (err) {
|
|
console.error("PUT /admin/weekly-schedules error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.put(
|
|
"/admin/weekly-schedules/:scheduleId/shift",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const scheduleId = Number(req.params.scheduleId);
|
|
const driverId = req.body?.driverId ? Number(req.body.driverId) : null;
|
|
if (!Number.isInteger(scheduleId))
|
|
return res.status(400).json({ error: "invalid_schedule_id" });
|
|
await db.query("DELETE FROM transit.shifts WHERE schedule_id = $1", [
|
|
scheduleId,
|
|
]);
|
|
if (driverId) {
|
|
await db.query(
|
|
"INSERT INTO transit.shifts (schedule_id, driver_id) VALUES ($1, $2)",
|
|
[scheduleId, driverId],
|
|
);
|
|
}
|
|
res.json({ scheduleId, driverId });
|
|
} catch (err) {
|
|
console.error("PUT /admin/weekly-schedules/:id/shift error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.delete(
|
|
"/admin/weekly-schedules/:scheduleId",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const scheduleId = Number(req.params.scheduleId);
|
|
if (!Number.isInteger(scheduleId))
|
|
return res.status(400).json({ error: "invalid_schedule_id" });
|
|
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, scheduleId });
|
|
} catch (err) {
|
|
console.error("DELETE /admin/weekly-schedules error:", err);
|
|
res.status(500).json({ error: "internal_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
"/admin/schedules",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const templatesOnly =
|
|
String(
|
|
req.query.templates || req.query.includeTemplates || "",
|
|
).toLowerCase() === "true" ||
|
|
String(req.query.templates || req.query.includeTemplates || "") === "1";
|
|
|
|
if (DEV_MOCK) {
|
|
const routeStopsByLine = new Map();
|
|
for (const routeStop of _mock.routeStops || []) {
|
|
const busLineId = Number(routeStop.busLineId);
|
|
if (!routeStopsByLine.has(busLineId)) {
|
|
routeStopsByLine.set(busLineId, []);
|
|
}
|
|
routeStopsByLine.get(busLineId).push({
|
|
stop_id: routeStop.stopId,
|
|
stop_order: routeStop.stopOrder,
|
|
});
|
|
}
|
|
|
|
const rawSchedules = (_mock.schedules || []).map((s) => {
|
|
const busLine = _mock.busLines.find((bl) => bl.id === s.busLineId);
|
|
const bus = _mock.fleetBuses.find((b) => b.id === s.busId);
|
|
return {
|
|
...s,
|
|
busLineId: s.busLineId,
|
|
busId: s.busId,
|
|
startTime: s.startTime,
|
|
endTime: s.endTime,
|
|
weekdays: s.weekdays || [],
|
|
status: s.status,
|
|
lineNumber: busLine?.lineNumber || 0,
|
|
busName: bus?.name || "Unknown Bus",
|
|
driverId: s.driverId || null,
|
|
estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
|
|
};
|
|
});
|
|
|
|
const schedules = (
|
|
templatesOnly
|
|
? rawSchedules
|
|
: expandRecurringScheduleRows(rawSchedules)
|
|
)
|
|
.filter((row) =>
|
|
scheduleMatchesFilters(
|
|
row,
|
|
parseScheduleFilters(req.query),
|
|
routeStopsByLine,
|
|
),
|
|
)
|
|
.map((s) => ({
|
|
...s,
|
|
departureTimes: calculateDepartureTimes(
|
|
s.startTime,
|
|
s.busLineId,
|
|
_mock,
|
|
),
|
|
}))
|
|
.sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
|
|
|
|
return res.json({ schedules });
|
|
}
|
|
|
|
const hasWeekdaysColumn = await db.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
|
|
`);
|
|
|
|
const baseSelect = `
|
|
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,
|
|
bl.estimated_duration_minutes,
|
|
b.name as bus_name,
|
|
b.license_plate,
|
|
TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) as driver_name`;
|
|
|
|
const result =
|
|
hasWeekdaysColumn.rows.length > 0
|
|
? await db.query(`${baseSelect}, s.weekdays as weekdays
|
|
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
|
|
LEFT JOIN workers.employees e ON s.driver_id = e.id
|
|
ORDER BY s.start_time DESC`)
|
|
: await db.query(`${baseSelect}, NULL as weekdays
|
|
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
|
|
LEFT JOIN workers.employees e ON s.driver_id = e.id
|
|
ORDER BY s.start_time DESC`);
|
|
|
|
const lineIds = Array.from(
|
|
new Set(
|
|
result.rows
|
|
.map((row) => Number(row.bus_line_id))
|
|
.filter(Number.isFinite),
|
|
),
|
|
);
|
|
|
|
const routeStopsByLine = new Map();
|
|
if (lineIds.length > 0) {
|
|
const routeStopsResult = await db.query(
|
|
`
|
|
SELECT
|
|
rs.bus_line_id,
|
|
rs.stop_id,
|
|
rs.stop_order,
|
|
rs.estimated_arrival_minutes,
|
|
s.name AS stop_name
|
|
FROM transit.route_stops rs
|
|
JOIN transit.stops s ON s.id = rs.stop_id
|
|
WHERE rs.bus_line_id = ANY($1::int[])
|
|
ORDER BY rs.bus_line_id ASC, rs.stop_order ASC
|
|
`,
|
|
[lineIds],
|
|
);
|
|
|
|
for (const row of routeStopsResult.rows) {
|
|
const busLineId = Number(row.bus_line_id);
|
|
if (!routeStopsByLine.has(busLineId)) {
|
|
routeStopsByLine.set(busLineId, []);
|
|
}
|
|
routeStopsByLine.get(busLineId).push(row);
|
|
}
|
|
}
|
|
|
|
const filters = parseScheduleFilters(req.query);
|
|
const rowSource = templatesOnly
|
|
? result.rows
|
|
: expandRecurringScheduleRows(result.rows);
|
|
const expandedSchedules = rowSource.filter((row) =>
|
|
scheduleMatchesFilters(row, filters, routeStopsByLine),
|
|
);
|
|
|
|
const schedules = expandedSchedules
|
|
.map((row) => mapAdminScheduleRow(row, routeStopsByLine))
|
|
.sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
|
|
|
|
res.json({ schedules });
|
|
} catch (err) {
|
|
console.error("GET /admin/schedules 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, weekdays } = req.body;
|
|
|
|
if (!busLineId || !busId || !startTime) {
|
|
return res.status(400).json({
|
|
error:
|
|
"missing_required_fields: busLineId, busId, startTime required",
|
|
});
|
|
}
|
|
|
|
// Validate weekdays - should be array of day numbers (0-6) or empty for single schedule
|
|
const scheduleWeekdays =
|
|
Array.isArray(weekdays) && weekdays.length > 0
|
|
? weekdays.map((w) => Number(w)).filter((w) => w >= 0 && w <= 6)
|
|
: [];
|
|
|
|
if (DEV_MOCK) {
|
|
if (!_mock.schedules) _mock.schedules = [];
|
|
|
|
// Find the bus line to get duration
|
|
const busLine = _mock.busLines.find((bl) => bl.id == busLineId);
|
|
if (!busLine) {
|
|
return res.status(404).json({ error: "bus_line_not_found" });
|
|
}
|
|
|
|
const startDate = new Date(startTime);
|
|
const endDate = new Date(startDate);
|
|
endDate.setMinutes(
|
|
endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60),
|
|
);
|
|
|
|
// Calculate departure times for stops
|
|
const departureTimes = calculateDepartureTimes(
|
|
startTime,
|
|
Number(busLineId),
|
|
_mock,
|
|
);
|
|
|
|
const schedule = {
|
|
id: _mock.nextScheduleId || 1,
|
|
busLineId,
|
|
busId,
|
|
driverId: driverId ? Number(driverId) : null,
|
|
startTime,
|
|
endTime: endDate.toISOString(),
|
|
weekdays: scheduleWeekdays,
|
|
departureTimes,
|
|
status: "planned",
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
_mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1;
|
|
_mock.schedules.push(schedule);
|
|
return res.status(201).json({ schedule });
|
|
}
|
|
|
|
// Database mode (production)
|
|
const busLine = await db.query(
|
|
"SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1",
|
|
[busLineId],
|
|
);
|
|
if (!busLine.rows[0]) {
|
|
return res.status(404).json({ error: "bus_line_not_found" });
|
|
}
|
|
|
|
const startDate = new Date(startTime);
|
|
const endDate = new Date(startDate);
|
|
endDate.setMinutes(
|
|
endDate.getMinutes() +
|
|
(busLine.rows[0].estimated_duration_minutes || 60),
|
|
);
|
|
|
|
// Check if weekdays column exists before including it in INSERT
|
|
const hasWeekdaysColumn = await db.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
|
|
`);
|
|
|
|
let result;
|
|
if (hasWeekdaysColumn.rows.length > 0) {
|
|
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, weekdays)
|
|
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6
|
|
FROM next_id
|
|
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
|
|
`,
|
|
[
|
|
busLineId,
|
|
busId,
|
|
driverId || null,
|
|
startTime,
|
|
endDate.toISOString(),
|
|
JSON.stringify(scheduleWeekdays),
|
|
],
|
|
);
|
|
} else {
|
|
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,
|
|
endDate.toISOString(),
|
|
],
|
|
);
|
|
}
|
|
|
|
const schedule = result.rows[0];
|
|
const departureTimes = calculateDepartureTimes(
|
|
startTime,
|
|
busLineId,
|
|
_mock,
|
|
);
|
|
|
|
const weekdaysResult = parseWeekdaysValue(schedule.weekdays);
|
|
|
|
res.status(201).json({
|
|
schedule: {
|
|
id: Number(schedule.id),
|
|
busLineId: Number(schedule.bus_line_id),
|
|
busId: Number(schedule.bus_id),
|
|
driverId:
|
|
schedule.driver_id !== null && schedule.driver_id !== undefined
|
|
? Number(schedule.driver_id)
|
|
: null,
|
|
startTime: schedule.start_time,
|
|
endTime: schedule.end_time,
|
|
weekdays: weekdaysResult,
|
|
departureTimes,
|
|
status: schedule.status,
|
|
createdAt: schedule.created_at,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.put(
|
|
"/admin/schedules/:scheduleId",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { scheduleId } = req.params;
|
|
const { busLineId, busId, driverId, startTime, weekdays } = req.body;
|
|
|
|
if (!scheduleId || !Number.isFinite(Number(scheduleId))) {
|
|
return res.status(400).json({ error: "invalid_schedule_id" });
|
|
}
|
|
|
|
if (!busLineId || !busId || !startTime) {
|
|
return res.status(400).json({
|
|
error:
|
|
"missing_required_fields: busLineId, busId, startTime required",
|
|
});
|
|
}
|
|
|
|
const scheduleWeekdays =
|
|
Array.isArray(weekdays) && weekdays.length > 0
|
|
? weekdays.map((w) => Number(w)).filter((w) => w >= 0 && w <= 6)
|
|
: [];
|
|
|
|
if (DEV_MOCK) {
|
|
const index = (_mock.schedules || []).findIndex(
|
|
(s) => Number(s.id) === Number(scheduleId),
|
|
);
|
|
if (index === -1) {
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
}
|
|
|
|
const busLine = _mock.busLines.find((bl) => bl.id == busLineId);
|
|
if (!busLine) {
|
|
return res.status(404).json({ error: "bus_line_not_found" });
|
|
}
|
|
|
|
const startDate = new Date(startTime);
|
|
const endDate = new Date(startDate);
|
|
endDate.setMinutes(
|
|
endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60),
|
|
);
|
|
|
|
_mock.schedules[index] = {
|
|
..._mock.schedules[index],
|
|
busLineId: Number(busLineId),
|
|
busId: Number(busId),
|
|
driverId: driverId ? Number(driverId) : null,
|
|
startTime,
|
|
endTime: endDate.toISOString(),
|
|
weekdays: scheduleWeekdays,
|
|
};
|
|
|
|
return res.json({ schedule: _mock.schedules[index] });
|
|
}
|
|
|
|
const existingResult = await db.query(
|
|
`
|
|
SELECT id, status
|
|
FROM transit.schedules
|
|
WHERE id = $1
|
|
`,
|
|
[scheduleId],
|
|
);
|
|
|
|
if (existingResult.rows.length === 0) {
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
}
|
|
|
|
const busLine = await db.query(
|
|
"SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1",
|
|
[busLineId],
|
|
);
|
|
if (!busLine.rows[0]) {
|
|
return res.status(404).json({ error: "bus_line_not_found" });
|
|
}
|
|
|
|
const startDate = new Date(startTime);
|
|
const endDate = new Date(startDate);
|
|
endDate.setMinutes(
|
|
endDate.getMinutes() +
|
|
(busLine.rows[0].estimated_duration_minutes || 60),
|
|
);
|
|
|
|
const hasWeekdaysColumn = await db.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
|
|
`);
|
|
|
|
let result;
|
|
if (hasWeekdaysColumn.rows.length > 0) {
|
|
result = await db.query(
|
|
`
|
|
UPDATE transit.schedules
|
|
SET
|
|
bus_line_id = $2,
|
|
bus_id = $3,
|
|
driver_id = $4,
|
|
start_time = $5,
|
|
end_time = $6,
|
|
weekdays = $7,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
|
|
`,
|
|
[
|
|
scheduleId,
|
|
busLineId,
|
|
busId,
|
|
driverId || null,
|
|
startTime,
|
|
endDate.toISOString(),
|
|
JSON.stringify(scheduleWeekdays),
|
|
],
|
|
);
|
|
} else {
|
|
result = await db.query(
|
|
`
|
|
UPDATE transit.schedules
|
|
SET
|
|
bus_line_id = $2,
|
|
bus_id = $3,
|
|
driver_id = $4,
|
|
start_time = $5,
|
|
end_time = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at
|
|
`,
|
|
[
|
|
scheduleId,
|
|
busLineId,
|
|
busId,
|
|
driverId || null,
|
|
startTime,
|
|
endDate.toISOString(),
|
|
],
|
|
);
|
|
}
|
|
|
|
const schedule = result.rows[0];
|
|
return res.json({
|
|
schedule: {
|
|
id: Number(schedule.id),
|
|
busLineId: Number(schedule.bus_line_id),
|
|
busId: Number(schedule.bus_id),
|
|
driverId:
|
|
schedule.driver_id !== null && schedule.driver_id !== undefined
|
|
? Number(schedule.driver_id)
|
|
: null,
|
|
startTime: schedule.start_time,
|
|
endTime: schedule.end_time,
|
|
weekdays: parseWeekdaysValue(schedule.weekdays),
|
|
status: schedule.status,
|
|
createdAt: schedule.created_at,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/admin/schedules/bulk",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
const requestedSchedules = Array.isArray(req.body?.schedules)
|
|
? req.body.schedules
|
|
: [];
|
|
|
|
if (requestedSchedules.length === 0) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "missing_required_fields: schedules array required" });
|
|
}
|
|
|
|
const normalizedSchedules = requestedSchedules.map((schedule, index) => {
|
|
const busLineId = Number(schedule?.busLineId);
|
|
const busId = Number(schedule?.busId);
|
|
const driverId =
|
|
schedule?.driverId !== null &&
|
|
schedule?.driverId !== undefined &&
|
|
schedule?.driverId !== ""
|
|
? Number(schedule.driverId)
|
|
: null;
|
|
const startTime = schedule?.startTime ? String(schedule.startTime) : "";
|
|
const weekdays =
|
|
Array.isArray(schedule?.weekdays) && schedule.weekdays.length > 0
|
|
? schedule.weekdays
|
|
.map((day) => Number(day))
|
|
.filter((day) => day >= 0 && day <= 6)
|
|
: [];
|
|
|
|
if (
|
|
!Number.isFinite(busLineId) ||
|
|
!Number.isFinite(busId) ||
|
|
!startTime
|
|
) {
|
|
throw new Error(`invalid_schedule_entry:${index}`);
|
|
}
|
|
|
|
return { busLineId, busId, driverId, startTime, weekdays };
|
|
});
|
|
|
|
try {
|
|
if (DEV_MOCK) {
|
|
if (!_mock.schedules) _mock.schedules = [];
|
|
|
|
const createdSchedules = [];
|
|
|
|
for (const entry of normalizedSchedules) {
|
|
const busLine = _mock.busLines.find((bl) => bl.id == entry.busLineId);
|
|
if (!busLine) {
|
|
return res.status(404).json({ error: "bus_line_not_found" });
|
|
}
|
|
|
|
const startDate = new Date(entry.startTime);
|
|
const endDate = new Date(startDate);
|
|
endDate.setMinutes(
|
|
endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60),
|
|
);
|
|
|
|
const departureTimes = calculateDepartureTimes(
|
|
entry.startTime,
|
|
Number(entry.busLineId),
|
|
_mock,
|
|
);
|
|
|
|
const schedule = {
|
|
id: _mock.nextScheduleId || 1,
|
|
busLineId: entry.busLineId,
|
|
busId: entry.busId,
|
|
driverId: entry.driverId,
|
|
startTime: entry.startTime,
|
|
endTime: endDate.toISOString(),
|
|
weekdays: entry.weekdays,
|
|
departureTimes,
|
|
status: "planned",
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
_mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1;
|
|
_mock.schedules.push(schedule);
|
|
createdSchedules.push(schedule);
|
|
}
|
|
|
|
return res.status(201).json({
|
|
createdCount: createdSchedules.length,
|
|
schedules: createdSchedules,
|
|
});
|
|
}
|
|
|
|
const dbClient = await db.pool.connect();
|
|
try {
|
|
await dbClient.query("BEGIN");
|
|
|
|
const hasWeekdaysColumn = await dbClient.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
|
|
`);
|
|
|
|
const createdSchedules = [];
|
|
|
|
for (const entry of normalizedSchedules) {
|
|
const busLine = await dbClient.query(
|
|
"SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1",
|
|
[entry.busLineId],
|
|
);
|
|
if (!busLine.rows[0]) {
|
|
throw new Error("bus_line_not_found");
|
|
}
|
|
|
|
const startDate = new Date(entry.startTime);
|
|
const endDate = new Date(startDate);
|
|
endDate.setMinutes(
|
|
endDate.getMinutes() +
|
|
(busLine.rows[0].estimated_duration_minutes || 60),
|
|
);
|
|
|
|
let result;
|
|
if (hasWeekdaysColumn.rows.length > 0) {
|
|
result = await dbClient.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, weekdays)
|
|
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6
|
|
FROM next_id
|
|
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
|
|
`,
|
|
[
|
|
entry.busLineId,
|
|
entry.busId,
|
|
entry.driverId,
|
|
entry.startTime,
|
|
endDate.toISOString(),
|
|
JSON.stringify(entry.weekdays),
|
|
],
|
|
);
|
|
} else {
|
|
result = await dbClient.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
|
|
`,
|
|
[
|
|
entry.busLineId,
|
|
entry.busId,
|
|
entry.driverId,
|
|
entry.startTime,
|
|
endDate.toISOString(),
|
|
],
|
|
);
|
|
}
|
|
|
|
const schedule = result.rows[0];
|
|
createdSchedules.push({
|
|
id: Number(schedule.id),
|
|
busLineId: Number(schedule.bus_line_id),
|
|
busId: Number(schedule.bus_id),
|
|
driverId:
|
|
schedule.driver_id !== null && schedule.driver_id !== undefined
|
|
? Number(schedule.driver_id)
|
|
: null,
|
|
startTime: schedule.start_time,
|
|
endTime: schedule.end_time,
|
|
weekdays: parseWeekdaysValue(schedule.weekdays),
|
|
status: schedule.status,
|
|
createdAt: schedule.created_at,
|
|
});
|
|
}
|
|
|
|
await dbClient.query("COMMIT");
|
|
return res.status(201).json({
|
|
createdCount: createdSchedules.length,
|
|
schedules: createdSchedules,
|
|
});
|
|
} catch (err) {
|
|
await dbClient.query("ROLLBACK");
|
|
throw err;
|
|
} finally {
|
|
dbClient.release();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
const errorMessage =
|
|
err.message === "bus_line_not_found"
|
|
? "bus_line_not_found"
|
|
: err.message.startsWith("invalid_schedule_entry:")
|
|
? err.message
|
|
: "internal_error";
|
|
const statusCode =
|
|
errorMessage === "bus_line_not_found"
|
|
? 404
|
|
: errorMessage.startsWith("invalid_schedule_entry:")
|
|
? 400
|
|
: 500;
|
|
res
|
|
.status(statusCode)
|
|
.json({ error: errorMessage, 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: { id: Number(result.rows[0].id) } });
|
|
} 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 filters = parseScheduleFilters(req.query, {
|
|
statuses: ["planned", "active"],
|
|
});
|
|
|
|
if (DEV_MOCK) {
|
|
const routeStopsByLine = new Map();
|
|
for (const routeStop of _mock.routeStops || []) {
|
|
const busLineId = Number(routeStop.busLineId);
|
|
if (!routeStopsByLine.has(busLineId)) {
|
|
routeStopsByLine.set(busLineId, []);
|
|
}
|
|
routeStopsByLine.get(busLineId).push({
|
|
stop_id: routeStop.stopId,
|
|
stop_order: routeStop.stopOrder,
|
|
});
|
|
}
|
|
|
|
const schedules = expandRecurringScheduleRows(
|
|
(_mock.schedules || []).map((s) => {
|
|
const busLine = _mock.busLines.find((bl) => bl.id === s.busLineId);
|
|
const bus = _mock.fleetBuses.find((b) => b.id === s.busId);
|
|
return {
|
|
id: s.id,
|
|
busLineId: s.busLineId,
|
|
busId: s.busId,
|
|
startTime: s.startTime,
|
|
endTime: s.endTime,
|
|
weekdays: s.weekdays || [],
|
|
status: s.status,
|
|
lineNumber: busLine?.lineNumber || 0,
|
|
busName: bus?.name || "Unknown Bus",
|
|
driverId: null,
|
|
estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
|
|
};
|
|
}),
|
|
).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine));
|
|
|
|
return res.json({ schedules });
|
|
}
|
|
|
|
let query = `
|
|
SELECT
|
|
s.id,
|
|
s.bus_line_id,
|
|
s.bus_id,
|
|
s.driver_id,
|
|
s.start_time,
|
|
s.end_time,
|
|
s.weekdays,
|
|
s.status,
|
|
bl.line_number,
|
|
b.name as bus_name,
|
|
bl.estimated_duration_minutes
|
|
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 1 = 1
|
|
`;
|
|
|
|
const params = [];
|
|
|
|
if (filters.statuses.length > 0) {
|
|
query += ` AND s.status = ANY($${params.length + 1}::text[])`;
|
|
params.push(filters.statuses);
|
|
}
|
|
|
|
if (filters.busLineId !== null) {
|
|
query += ` AND s.bus_line_id = $${params.length + 1}`;
|
|
params.push(filters.busLineId);
|
|
}
|
|
|
|
if (filters.busId !== null) {
|
|
query += ` AND s.bus_id = $${params.length + 1}`;
|
|
params.push(filters.busId);
|
|
}
|
|
|
|
if (filters.driverId !== null) {
|
|
query += ` AND s.driver_id = $${params.length + 1}`;
|
|
params.push(filters.driverId);
|
|
}
|
|
|
|
query += ` ORDER BY s.start_time ASC`;
|
|
|
|
const result = await db.query(query, params);
|
|
|
|
const lineIds = Array.from(
|
|
new Set(
|
|
result.rows
|
|
.map((row) => Number(row.bus_line_id))
|
|
.filter(Number.isFinite),
|
|
),
|
|
);
|
|
const routeStopsByLine = new Map();
|
|
|
|
if (filters.stopId !== null && lineIds.length > 0) {
|
|
const routeStopsResult = await db.query(
|
|
`
|
|
SELECT
|
|
rs.bus_line_id,
|
|
rs.stop_id,
|
|
rs.stop_order
|
|
FROM transit.route_stops rs
|
|
WHERE rs.bus_line_id = ANY($1::int[])
|
|
ORDER BY rs.bus_line_id ASC, rs.stop_order ASC
|
|
`,
|
|
[lineIds],
|
|
);
|
|
|
|
for (const row of routeStopsResult.rows) {
|
|
const busLineId = Number(row.bus_line_id);
|
|
if (!routeStopsByLine.has(busLineId)) {
|
|
routeStopsByLine.set(busLineId, []);
|
|
}
|
|
routeStopsByLine.get(busLineId).push(row);
|
|
}
|
|
}
|
|
|
|
const schedules = expandRecurringScheduleRows(result.rows)
|
|
.filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine))
|
|
.map((row) => ({
|
|
id: Number(row.id),
|
|
busLineId: Number(row.bus_line_id),
|
|
busId: Number(row.bus_id),
|
|
// Hide driver from public API responses
|
|
driverId: null,
|
|
startTime: row.start_time,
|
|
endTime: row.end_time,
|
|
weekdays: row.weekdays
|
|
? typeof row.weekdays === "string"
|
|
? JSON.parse(row.weekdays)
|
|
: row.weekdays
|
|
: [],
|
|
status: row.status,
|
|
lineNumber: row.line_number,
|
|
busName: row.bus_name,
|
|
estimatedDurationMinutes:
|
|
Number(row.estimated_duration_minutes) || null,
|
|
isRecurringInstance: row.is_recurring_instance || false,
|
|
templateId: row.template_id || null,
|
|
}));
|
|
|
|
res.json({ schedules });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/schedules/overview", async (req, res) => {
|
|
try {
|
|
const filters = parseScheduleFilters(req.query, {
|
|
statuses: ["planned", "active"],
|
|
});
|
|
const overviewDateKey = filters.date || null;
|
|
|
|
if (DEV_MOCK) {
|
|
const routeStopsByLine = new Map();
|
|
|
|
for (const routeStop of _mock.routeStops || []) {
|
|
const busLineId = Number(routeStop.busLineId);
|
|
if (!routeStopsByLine.has(busLineId)) {
|
|
routeStopsByLine.set(busLineId, []);
|
|
}
|
|
|
|
routeStopsByLine.get(busLineId).push({
|
|
bus_line_id: busLineId,
|
|
stop_id: routeStop.stopId,
|
|
stop_order: routeStop.stopOrder,
|
|
estimated_arrival_minutes: routeStop.estimatedArrivalMinutes,
|
|
stop_name: routeStop.name || "",
|
|
});
|
|
}
|
|
|
|
const baseRows = (_mock.schedules || []).map((schedule) => {
|
|
const busLine = _mock.busLines.find(
|
|
(line) => Number(line.id) === Number(schedule.busLineId),
|
|
);
|
|
const bus = _mock.fleetBuses.find(
|
|
(vehicle) => Number(vehicle.id) === Number(schedule.busId),
|
|
);
|
|
const driver = _mock.drivers.find(
|
|
(person) => Number(person.id) === Number(schedule.driverId),
|
|
);
|
|
|
|
return {
|
|
id: schedule.id,
|
|
busLineId: schedule.busLineId,
|
|
busId: schedule.busId,
|
|
driverId: schedule.driverId ?? null,
|
|
driverName: driver ? driver.name : null,
|
|
startTime: schedule.startTime,
|
|
endTime: schedule.endTime,
|
|
weekdays: schedule.weekdays || [],
|
|
status: schedule.status,
|
|
lineNumber: busLine?.lineNumber || 0,
|
|
busName: bus?.name || "Unknown Bus",
|
|
licensePlate: bus?.licensePlate || "",
|
|
createdAt: schedule.createdAt,
|
|
};
|
|
});
|
|
|
|
const schedules = mapRecurringOccurrenceRows(
|
|
baseRows,
|
|
overviewDateKey,
|
|
routeStopsByLine,
|
|
).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine));
|
|
|
|
return res.json({
|
|
date: overviewDateKey,
|
|
summary: {
|
|
total: schedules.length,
|
|
recurring: schedules.filter(
|
|
(schedule) => schedule.scheduleType === "recurring",
|
|
).length,
|
|
oneTime: schedules.filter(
|
|
(schedule) => schedule.scheduleType === "one_time",
|
|
).length,
|
|
},
|
|
schedules,
|
|
});
|
|
}
|
|
|
|
const hasWeekdaysColumn = await db.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'transit' AND table_name = 'schedules' AND column_name = 'weekdays'
|
|
`);
|
|
|
|
const baseSelect = `
|
|
SELECT
|
|
s.id,
|
|
s.bus_line_id AS "busLineId",
|
|
s.bus_id AS "busId",
|
|
s.driver_id AS "driverId",
|
|
s.start_time AS "startTime",
|
|
s.end_time AS "endTime",
|
|
s.status,
|
|
s.created_at AS "createdAt",
|
|
bl.line_number AS "lineNumber",
|
|
bl.estimated_duration_minutes AS "estimatedDurationMinutes",
|
|
b.name AS "busName",
|
|
b.license_plate AS "licensePlate",
|
|
TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) AS "driverName"`;
|
|
|
|
const result =
|
|
hasWeekdaysColumn.rows.length > 0
|
|
? await db.query(
|
|
`${baseSelect}, s.weekdays AS weekdays
|
|
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
|
|
LEFT JOIN workers.employees e ON s.driver_id = e.id
|
|
WHERE s.status = ANY($1::text[])
|
|
ORDER BY s.start_time ASC`,
|
|
[filters.statuses],
|
|
)
|
|
: await db.query(
|
|
`${baseSelect}, NULL AS weekdays
|
|
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
|
|
LEFT JOIN workers.employees e ON s.driver_id = e.id
|
|
WHERE s.status = ANY($1::text[])
|
|
ORDER BY s.start_time ASC`,
|
|
[filters.statuses],
|
|
);
|
|
|
|
const lineIds = Array.from(
|
|
new Set(
|
|
result.rows.map((row) => Number(row.busLineId)).filter(Number.isFinite),
|
|
),
|
|
);
|
|
|
|
const routeStopsByLine = new Map();
|
|
|
|
if (lineIds.length > 0) {
|
|
const routeStopsResult = await db.query(
|
|
`
|
|
SELECT
|
|
rs.bus_line_id,
|
|
rs.stop_id,
|
|
rs.stop_order,
|
|
rs.estimated_arrival_minutes,
|
|
s.name AS stop_name
|
|
FROM transit.route_stops rs
|
|
JOIN transit.stops s ON s.id = rs.stop_id
|
|
WHERE rs.bus_line_id = ANY($1::int[])
|
|
ORDER BY rs.bus_line_id ASC, rs.stop_order ASC
|
|
`,
|
|
[lineIds],
|
|
);
|
|
|
|
for (const row of routeStopsResult.rows) {
|
|
const busLineId = Number(row.bus_line_id);
|
|
if (!routeStopsByLine.has(busLineId)) {
|
|
routeStopsByLine.set(busLineId, []);
|
|
}
|
|
|
|
routeStopsByLine.get(busLineId).push(row);
|
|
}
|
|
}
|
|
|
|
let schedules = [];
|
|
if (filters.fromDate && filters.toDate) {
|
|
schedules = materializeScheduleRowsInRange(
|
|
result.rows,
|
|
filters.fromDate,
|
|
filters.toDate,
|
|
routeStopsByLine,
|
|
).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine));
|
|
} else {
|
|
schedules = mapRecurringOccurrenceRows(
|
|
result.rows,
|
|
overviewDateKey,
|
|
routeStopsByLine,
|
|
).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine));
|
|
}
|
|
|
|
// Enrich schedules with live progress for the occurrence date(s)
|
|
if (!DEV_MOCK && schedules.length > 0) {
|
|
const scheduleIds = [
|
|
...new Set(schedules.map((s) => Number(s.id)).filter(Number.isFinite)),
|
|
];
|
|
const occurrenceDates = [
|
|
...new Set(
|
|
schedules.map((s) => toDateKey(s.startTime)).filter(Boolean),
|
|
),
|
|
];
|
|
|
|
if (scheduleIds.length > 0 && occurrenceDates.length > 0) {
|
|
const progressRes = await db.query(
|
|
`
|
|
SELECT schedule_id, scheduled_passed_date, stop_order, actual_passed_at, scheduled_passed_at, delay_minutes
|
|
FROM transit.schedule_stop_progress
|
|
WHERE schedule_id = ANY($1::int[]) AND scheduled_passed_date = ANY($2::date[])
|
|
ORDER BY schedule_id ASC, scheduled_passed_date ASC, actual_passed_at ASC
|
|
`,
|
|
[scheduleIds, occurrenceDates],
|
|
);
|
|
|
|
const progressByKey = new Map();
|
|
for (const row of progressRes.rows) {
|
|
const key = `${row.schedule_id}|${row.scheduled_passed_date}`;
|
|
if (!progressByKey.has(key)) progressByKey.set(key, []);
|
|
progressByKey.get(key).push(row);
|
|
}
|
|
|
|
schedules = schedules.map((s) => {
|
|
const key = `${s.id}|${toDateKey(s.startTime)}`;
|
|
const progRows = progressByKey.get(key) || [];
|
|
const totalStops = (routeStopsByLine.get(Number(s.busLineId)) || [])
|
|
.length;
|
|
const passedStops = progRows.length;
|
|
let currentDelayMinutes = null;
|
|
if (progRows.length > 0) {
|
|
const latest = progRows[progRows.length - 1];
|
|
currentDelayMinutes =
|
|
Number(latest.delay_minutes ?? latest.delay_minutes) || null;
|
|
}
|
|
|
|
const statusOverride =
|
|
passedStops >= totalStops && totalStops > 0
|
|
? "completed"
|
|
: passedStops > 0
|
|
? "in_progress"
|
|
: s.status;
|
|
|
|
return {
|
|
...s,
|
|
status: statusOverride,
|
|
passedStops,
|
|
totalStops,
|
|
currentDelayMinutes,
|
|
};
|
|
});
|
|
}
|
|
} else if (DEV_MOCK && schedules.length > 0) {
|
|
const mockProgress = _mock.scheduleStopProgress || [];
|
|
const progressByKey = new Map();
|
|
for (const p of mockProgress) {
|
|
const key = `${p.scheduleId}|${p.scheduledPassedDate}`;
|
|
if (!progressByKey.has(key)) progressByKey.set(key, []);
|
|
progressByKey.get(key).push(p);
|
|
}
|
|
|
|
schedules = schedules.map((s) => {
|
|
const key = `${s.id}|${toDateKey(s.startTime)}`;
|
|
const progRows = progressByKey.get(key) || [];
|
|
const totalStops = (routeStopsByLine.get(Number(s.busLineId)) || [])
|
|
.length;
|
|
const passedStops = progRows.length;
|
|
const latest =
|
|
progRows.length > 0 ? progRows[progRows.length - 1] : null;
|
|
const currentDelayMinutes = latest
|
|
? Number(latest.delayMinutes) || 0
|
|
: null;
|
|
const statusOverride =
|
|
passedStops >= totalStops && totalStops > 0
|
|
? "completed"
|
|
: passedStops > 0
|
|
? "in_progress"
|
|
: s.status;
|
|
return {
|
|
...s,
|
|
status: statusOverride,
|
|
passedStops,
|
|
totalStops,
|
|
currentDelayMinutes,
|
|
};
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
date: overviewDateKey,
|
|
summary: {
|
|
total: schedules.length,
|
|
recurring: schedules.filter(
|
|
(schedule) => schedule.scheduleType === "recurring",
|
|
).length,
|
|
oneTime: schedules.filter(
|
|
(schedule) => schedule.scheduleType === "one_time",
|
|
).length,
|
|
},
|
|
schedules,
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// PUBLIC - Get all recurring schedule templates (no date filtering)
|
|
// ============================================
|
|
app.get("/schedules/recurring", async (req, res) => {
|
|
try {
|
|
const filters = parseScheduleFilters(req.query);
|
|
|
|
if (DEV_MOCK) {
|
|
const routeStopsByLine = new Map();
|
|
|
|
for (const routeStop of _mock.routeStops || []) {
|
|
const busLineId = Number(routeStop.busLineId);
|
|
if (!routeStopsByLine.has(busLineId)) {
|
|
routeStopsByLine.set(busLineId, []);
|
|
}
|
|
|
|
routeStopsByLine.get(busLineId).push({
|
|
bus_line_id: busLineId,
|
|
stop_id: routeStop.stopId,
|
|
stop_order: routeStop.stopOrder,
|
|
estimated_arrival_minutes: routeStop.estimatedArrivalMinutes,
|
|
stop_name: routeStop.name || "",
|
|
});
|
|
}
|
|
|
|
const baseRows = (_mock.schedules || [])
|
|
.filter(
|
|
(schedule) =>
|
|
Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0,
|
|
)
|
|
.map((schedule) => {
|
|
const busLine = _mock.busLines.find(
|
|
(line) => Number(line.id) === Number(schedule.busLineId),
|
|
);
|
|
const bus = _mock.fleetBuses.find(
|
|
(vehicle) => Number(vehicle.id) === Number(schedule.busId),
|
|
);
|
|
const driver = _mock.drivers.find(
|
|
(person) => Number(person.id) === Number(schedule.driverId),
|
|
);
|
|
|
|
return {
|
|
id: schedule.id,
|
|
busLineId: schedule.busLineId,
|
|
busId: schedule.busId,
|
|
driverId: schedule.driverId ?? null,
|
|
driverName: driver ? driver.name : null,
|
|
startTime: schedule.startTime,
|
|
endTime: schedule.endTime,
|
|
weekdays: schedule.weekdays || [],
|
|
lineNumber: busLine?.lineNumber || 0,
|
|
busName: bus?.name || "Unknown Bus",
|
|
licensePlate: bus?.licensePlate || "",
|
|
createdAt: schedule.createdAt,
|
|
};
|
|
});
|
|
|
|
const schedules = baseRows
|
|
.filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine))
|
|
.map((row) => {
|
|
const materialized = materializeScheduleOverviewRow(
|
|
row,
|
|
null,
|
|
routeStopsByLine,
|
|
);
|
|
return {
|
|
...materialized,
|
|
status: undefined,
|
|
};
|
|
});
|
|
|
|
return res.json({ schedules });
|
|
}
|
|
|
|
const hasWeekdaysColumn = await db.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'transit' AND table_name = 'schedules' AND column_name = 'weekdays'
|
|
`);
|
|
|
|
if (hasWeekdaysColumn.rows.length === 0) {
|
|
return res.json({ schedules: [] });
|
|
}
|
|
|
|
const result = await db.query(`
|
|
SELECT
|
|
s.id,
|
|
s.bus_line_id AS "busLineId",
|
|
s.bus_id AS "busId",
|
|
s.driver_id AS "driverId",
|
|
s.start_time AS "startTime",
|
|
s.end_time AS "endTime",
|
|
s.weekdays,
|
|
bl.line_number AS "lineNumber",
|
|
b.name AS "busName",
|
|
b.license_plate AS "licensePlate",
|
|
TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) AS "driverName",
|
|
s.created_at AS "createdAt"
|
|
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
|
|
LEFT JOIN workers.employees e ON s.driver_id = e.id
|
|
WHERE s.weekdays IS NOT NULL
|
|
`);
|
|
|
|
const lineIds = Array.from(
|
|
new Set(
|
|
result.rows.map((row) => Number(row.busLineId)).filter(Number.isFinite),
|
|
),
|
|
);
|
|
const routeStopsByLine = new Map();
|
|
|
|
if (lineIds.length > 0) {
|
|
const routeStopsResult = await db.query(
|
|
`
|
|
SELECT
|
|
rs.bus_line_id,
|
|
rs.stop_id,
|
|
rs.stop_order,
|
|
rs.estimated_arrival_minutes,
|
|
s.name AS stop_name
|
|
FROM transit.route_stops rs
|
|
JOIN transit.stops s ON s.id = rs.stop_id
|
|
WHERE rs.bus_line_id = ANY($1::int[])
|
|
ORDER BY rs.bus_line_id ASC, rs.stop_order ASC
|
|
`,
|
|
[lineIds],
|
|
);
|
|
|
|
for (const row of routeStopsResult.rows) {
|
|
const busLineId = Number(row.bus_line_id);
|
|
if (!routeStopsByLine.has(busLineId)) {
|
|
routeStopsByLine.set(busLineId, []);
|
|
}
|
|
routeStopsByLine.get(busLineId).push(row);
|
|
}
|
|
}
|
|
|
|
const schedules = result.rows
|
|
.filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine))
|
|
.map((row) => {
|
|
const schedule = materializeScheduleOverviewRow(
|
|
row,
|
|
null,
|
|
routeStopsByLine,
|
|
);
|
|
delete schedule.status;
|
|
return schedule;
|
|
});
|
|
|
|
res.json({ schedules });
|
|
} 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" });
|
|
}
|
|
|
|
// Enrich with bus line and fleet bus info
|
|
const busLine = _mock.busLines.find((bl) => bl.id === schedule.busLineId);
|
|
const bus = _mock.fleetBuses.find((b) => b.id === schedule.busId);
|
|
|
|
return res.json({
|
|
schedule: {
|
|
id: schedule.id,
|
|
busLineId: schedule.busLineId,
|
|
busId: schedule.busId,
|
|
startTime: schedule.startTime,
|
|
endTime: schedule.endTime,
|
|
weekdays: schedule.weekdays || [],
|
|
status: schedule.status,
|
|
lineNumber: busLine?.lineNumber || 0,
|
|
busName: bus?.name || "Unknown Bus",
|
|
},
|
|
});
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
SELECT
|
|
s.id,
|
|
s.bus_line_id,
|
|
s.bus_id,
|
|
s.start_time,
|
|
s.end_time,
|
|
s.status,
|
|
s.weekdays,
|
|
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];
|
|
const weekdays = parseWeekdaysValue(row.weekdays);
|
|
|
|
res.json({
|
|
schedule: {
|
|
id: Number(row.id),
|
|
busLineId: Number(row.bus_line_id),
|
|
busId: Number(row.bus_id),
|
|
startTime: row.start_time,
|
|
endTime: row.end_time,
|
|
status: row.status,
|
|
weekdays: weekdays,
|
|
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);
|
|
|
|
// Map to match database response format
|
|
const formattedStops = routeStops.map((rs) => {
|
|
const stop = (_mock.stops || []).find(
|
|
(s) => Number(s.id) === Number(rs.stopId),
|
|
);
|
|
return {
|
|
id: rs.id,
|
|
order: rs.stopOrder,
|
|
name: stop?.name || rs.name || "Unknown Stop",
|
|
latitude: stop?.latitude ?? rs.latitude ?? 0,
|
|
longitude: stop?.longitude ?? rs.longitude ?? 0,
|
|
estimatedArrivalMinutes: rs.estimatedArrivalMinutes || 0,
|
|
};
|
|
});
|
|
return res.json({ stops: formattedStops });
|
|
}
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// DRIVER - Mark stop as passed (updates realtime state)
|
|
// ============================================
|
|
app.post(
|
|
"/driver/schedules/:scheduleId/stops/:stopOrder/passed",
|
|
authMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const scheduleId = Number(req.params.scheduleId);
|
|
const stopOrder = Number(req.params.stopOrder);
|
|
|
|
if (
|
|
!Number.isFinite(scheduleId) ||
|
|
!Number.isFinite(stopOrder) ||
|
|
stopOrder < 1
|
|
) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "invalid_schedule_or_stop_order" });
|
|
}
|
|
|
|
const actualPassedAt = req.body?.actualPassedAt
|
|
? new Date(req.body.actualPassedAt)
|
|
: new Date();
|
|
if (Number.isNaN(actualPassedAt.getTime())) {
|
|
return res.status(400).json({ error: "invalid_actual_passed_at" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const schedule = (_mock.schedules || []).find(
|
|
(s) => Number(s.id) === scheduleId,
|
|
);
|
|
if (!schedule) {
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
}
|
|
|
|
const routeStop = (_mock.routeStops || []).find(
|
|
(rs) =>
|
|
Number(rs.busLineId) === Number(schedule.busLineId) &&
|
|
Number(rs.stopOrder) === stopOrder,
|
|
);
|
|
|
|
if (!routeStop) {
|
|
return res.status(404).json({ error: "stop_not_found_for_schedule" });
|
|
}
|
|
|
|
const stop = (_mock.stops || []).find(
|
|
(s) => Number(s.id) === Number(routeStop.stopId),
|
|
);
|
|
// Allow caller to specify which occurrence date this pertains to (for recurring templates)
|
|
const occurrenceDateKey =
|
|
req.query.date || req.body.date || toDateKey(schedule.startTime);
|
|
const templateStart = new Date(schedule.startTime);
|
|
const scheduledPassedAt = new Date(`${occurrenceDateKey}T00:00:00`);
|
|
scheduledPassedAt.setHours(
|
|
templateStart.getHours(),
|
|
templateStart.getMinutes(),
|
|
templateStart.getSeconds(),
|
|
templateStart.getMilliseconds(),
|
|
);
|
|
scheduledPassedAt.setMinutes(
|
|
scheduledPassedAt.getMinutes() +
|
|
(Number(routeStop.estimatedArrivalMinutes) || 0),
|
|
);
|
|
|
|
const existingIndex = (_mock.scheduleStopProgress || []).findIndex(
|
|
(p) =>
|
|
Number(p.scheduleId) === scheduleId &&
|
|
Number(p.stopOrder) === stopOrder,
|
|
);
|
|
|
|
const progressRow = {
|
|
id:
|
|
existingIndex >= 0
|
|
? _mock.scheduleStopProgress[existingIndex].id
|
|
: _mock.nextScheduleStopProgressId++,
|
|
scheduleId,
|
|
busLineId: Number(schedule.busLineId),
|
|
stopId: Number(routeStop.stopId),
|
|
stopOrder,
|
|
stopName: stop?.name || routeStop.name || "Unknown Stop",
|
|
estimatedArrivalMinutes:
|
|
Number(routeStop.estimatedArrivalMinutes) || 0,
|
|
scheduledPassedAt: scheduledPassedAt.toISOString(),
|
|
scheduledPassedDate: occurrenceDateKey,
|
|
actualPassedAt: actualPassedAt.toISOString(),
|
|
delayMinutes,
|
|
markedByUserId: Number(req.user?.userId) || null,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
if (existingIndex >= 0) {
|
|
_mock.scheduleStopProgress[existingIndex] = progressRow;
|
|
} else {
|
|
_mock.scheduleStopProgress.push(progressRow);
|
|
}
|
|
|
|
const totalStops = (_mock.routeStops || []).filter(
|
|
(rs) => Number(rs.busLineId) === Number(schedule.busLineId),
|
|
).length;
|
|
const passedCount = (_mock.scheduleStopProgress || []).filter(
|
|
(p) =>
|
|
Number(p.scheduleId) === scheduleId &&
|
|
p.scheduledPassedDate === occurrenceDateKey,
|
|
).length;
|
|
|
|
if (passedCount >= totalStops) {
|
|
schedule.status = "completed";
|
|
schedule.endTime = actualPassedAt.toISOString();
|
|
} else if (schedule.status === "planned") {
|
|
schedule.status = "in_progress";
|
|
}
|
|
|
|
return res.json({
|
|
scheduleId,
|
|
stopOrder,
|
|
status: schedule.status,
|
|
delayMinutes,
|
|
passedStops: passedCount,
|
|
totalStops,
|
|
occurrenceDate: occurrenceDateKey,
|
|
});
|
|
}
|
|
|
|
await ensureScheduleStopProgressTable();
|
|
|
|
const scheduleResult = await db.query(
|
|
`
|
|
SELECT s.id, s.bus_line_id, s.start_time, s.status
|
|
FROM transit.schedules s
|
|
WHERE s.id = $1
|
|
`,
|
|
[scheduleId],
|
|
);
|
|
|
|
if (scheduleResult.rows.length === 0) {
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
}
|
|
|
|
const schedule = scheduleResult.rows[0];
|
|
|
|
const routeStopResult = await db.query(
|
|
`
|
|
SELECT
|
|
rs.stop_id,
|
|
rs.stop_order,
|
|
rs.estimated_arrival_minutes,
|
|
s.name AS stop_name
|
|
FROM transit.route_stops rs
|
|
JOIN transit.stops s ON s.id = rs.stop_id
|
|
WHERE rs.bus_line_id = $1
|
|
AND rs.stop_order = $2
|
|
LIMIT 1
|
|
`,
|
|
[schedule.bus_line_id, stopOrder],
|
|
);
|
|
|
|
if (routeStopResult.rows.length === 0) {
|
|
return res.status(404).json({ error: "stop_not_found_for_schedule" });
|
|
}
|
|
|
|
const routeStop = routeStopResult.rows[0];
|
|
// Determine occurrence date (for recurring templates callers can pass ?date=YYYY-MM-DD or body.date)
|
|
let occurrenceDateKey =
|
|
req.query.date || req.body.date || toDateKey(schedule.start_time);
|
|
|
|
// If this schedule is a recurring template, map to or create a concrete instance for the occurrence date
|
|
const weekdays = parseWeekdaysValue(schedule.weekdays);
|
|
let instanceScheduleId = scheduleId;
|
|
if (weekdays && weekdays.length > 0) {
|
|
// If no date provided but template occurs today, use today
|
|
if (!occurrenceDateKey) {
|
|
const todayKey = new Date().toISOString().slice(0, 10);
|
|
if (scheduleOccursOnDate(schedule, todayKey))
|
|
occurrenceDateKey = todayKey;
|
|
}
|
|
|
|
if (occurrenceDateKey) {
|
|
const existingInstance = await db.query(
|
|
`SELECT id FROM transit.schedules WHERE template_id = $1 AND DATE(start_time) = $2 LIMIT 1`,
|
|
[scheduleId, occurrenceDateKey],
|
|
);
|
|
|
|
if (existingInstance.rows.length > 0) {
|
|
instanceScheduleId = existingInstance.rows[0].id;
|
|
} else {
|
|
// Create instance from template
|
|
const templateStart = new Date(schedule.start_time);
|
|
const templateEnd = schedule.end_time
|
|
? new Date(schedule.end_time)
|
|
: null;
|
|
const occurrenceStart = new Date(`${occurrenceDateKey}T00:00:00`);
|
|
occurrenceStart.setHours(
|
|
templateStart.getHours(),
|
|
templateStart.getMinutes(),
|
|
templateStart.getSeconds(),
|
|
templateStart.getMilliseconds(),
|
|
);
|
|
|
|
let occurrenceEnd = null;
|
|
if (
|
|
templateEnd &&
|
|
Number.isFinite(templateEnd.getTime()) &&
|
|
Number.isFinite(templateStart.getTime())
|
|
) {
|
|
const duration = templateEnd.getTime() - templateStart.getTime();
|
|
occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
|
|
}
|
|
|
|
const hasTemplateIdCol = await db.query(`
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema='transit' AND table_name='schedules' AND column_name='template_id'
|
|
`);
|
|
|
|
let insertRes;
|
|
if (hasTemplateIdCol.rows.length > 0) {
|
|
insertRes = 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, template_id)
|
|
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6
|
|
FROM next_id
|
|
RETURNING id
|
|
`,
|
|
[
|
|
schedule.bus_line_id,
|
|
schedule.bus_id,
|
|
schedule.driver_id,
|
|
occurrenceStart.toISOString(),
|
|
occurrenceEnd ? occurrenceEnd.toISOString() : null,
|
|
scheduleId,
|
|
],
|
|
);
|
|
} else {
|
|
insertRes = 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
|
|
`,
|
|
[
|
|
schedule.bus_line_id,
|
|
schedule.bus_id,
|
|
schedule.driver_id,
|
|
occurrenceStart.toISOString(),
|
|
occurrenceEnd ? occurrenceEnd.toISOString() : null,
|
|
],
|
|
);
|
|
}
|
|
|
|
instanceScheduleId = insertRes.rows[0].id;
|
|
}
|
|
}
|
|
}
|
|
|
|
const templateStart = new Date(schedule.start_time);
|
|
const scheduledPassedAt = new Date(`${occurrenceDateKey}T00:00:00`);
|
|
scheduledPassedAt.setHours(
|
|
templateStart.getHours(),
|
|
templateStart.getMinutes(),
|
|
templateStart.getSeconds(),
|
|
templateStart.getMilliseconds(),
|
|
);
|
|
scheduledPassedAt.setMinutes(
|
|
scheduledPassedAt.getMinutes() +
|
|
(Number(routeStop.estimated_arrival_minutes) || 0),
|
|
);
|
|
|
|
await db.query(
|
|
`
|
|
INSERT INTO transit.schedule_stop_progress (
|
|
schedule_id,
|
|
bus_line_id,
|
|
stop_id,
|
|
stop_order,
|
|
stop_name,
|
|
estimated_arrival_minutes,
|
|
scheduled_passed_at,
|
|
scheduled_passed_date,
|
|
actual_passed_at,
|
|
marked_by_user_id
|
|
)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
ON CONFLICT (schedule_id, stop_order, scheduled_passed_date)
|
|
DO UPDATE SET
|
|
actual_passed_at = EXCLUDED.actual_passed_at,
|
|
marked_by_user_id = EXCLUDED.marked_by_user_id,
|
|
created_at = NOW()
|
|
`,
|
|
[
|
|
instanceScheduleId,
|
|
Number(schedule.bus_line_id),
|
|
Number(routeStop.stop_id),
|
|
stopOrder,
|
|
routeStop.stop_name,
|
|
Number(routeStop.estimated_arrival_minutes) || 0,
|
|
scheduledPassedAt.toISOString(),
|
|
occurrenceDateKey,
|
|
actualPassedAt.toISOString(),
|
|
Number(req.user?.userId) || null,
|
|
],
|
|
);
|
|
|
|
const totalsResult = await db.query(
|
|
`
|
|
SELECT
|
|
(SELECT COUNT(*)::int FROM transit.route_stops WHERE bus_line_id = $1) AS total_stops,
|
|
(SELECT COUNT(*)::int FROM transit.schedule_stop_progress WHERE schedule_id = $2 AND scheduled_passed_date = $3) AS passed_stops
|
|
`,
|
|
[schedule.bus_line_id, instanceScheduleId, occurrenceDateKey],
|
|
);
|
|
|
|
const totalStops = Number(totalsResult.rows[0]?.total_stops || 0);
|
|
const passedStops = Number(totalsResult.rows[0]?.passed_stops || 0);
|
|
const isCompleted = totalStops > 0 && passedStops >= totalStops;
|
|
const nextStatus = isCompleted ? "completed" : "in_progress";
|
|
|
|
await db.query(
|
|
`
|
|
UPDATE transit.schedules
|
|
SET
|
|
status = $2,
|
|
end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END
|
|
WHERE id = $1
|
|
`,
|
|
[instanceScheduleId, nextStatus, actualPassedAt.toISOString()],
|
|
);
|
|
|
|
// Calculate delay live
|
|
const delayMinutes = Math.round(
|
|
(actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000,
|
|
);
|
|
|
|
res.json({
|
|
scheduleId,
|
|
stopOrder,
|
|
status: nextStatus,
|
|
delayMinutes,
|
|
passedStops,
|
|
totalStops,
|
|
});
|
|
} catch (err) {
|
|
console.error(
|
|
"POST /driver/schedules/:scheduleId/stops/:stopOrder/passed error:",
|
|
err,
|
|
);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
// ============================================
|
|
// PUBLIC - Live progress for passengers
|
|
// ============================================
|
|
app.get("/public/schedules/:scheduleId/progress", async (req, res) => {
|
|
try {
|
|
const scheduleId = Number(req.params.scheduleId);
|
|
if (!Number.isFinite(scheduleId)) {
|
|
return res.status(400).json({ error: "invalid_schedule_id" });
|
|
}
|
|
|
|
if (DEV_MOCK) {
|
|
const schedule = (_mock.schedules || []).find(
|
|
(s) => Number(s.id) === scheduleId,
|
|
);
|
|
if (!schedule) {
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
}
|
|
|
|
const busLine = (_mock.busLines || []).find(
|
|
(bl) => Number(bl.id) === Number(schedule.busLineId),
|
|
);
|
|
const routeStops = (_mock.routeStops || [])
|
|
.filter((rs) => Number(rs.busLineId) === Number(schedule.busLineId))
|
|
.sort((a, b) => Number(a.stopOrder) - Number(b.stopOrder));
|
|
|
|
const mockProgressDate =
|
|
req.query.date || req.query.occurrenceDate || null;
|
|
let progressEvents = (_mock.scheduleStopProgress || []).filter(
|
|
(p) => Number(p.scheduleId) === scheduleId,
|
|
);
|
|
if (mockProgressDate) {
|
|
progressEvents = progressEvents.filter(
|
|
(p) => p.scheduledPassedDate === mockProgressDate,
|
|
);
|
|
}
|
|
progressEvents = progressEvents.sort(
|
|
(a, b) => Number(a.stopOrder) - Number(b.stopOrder),
|
|
);
|
|
|
|
const progressByOrder = new Map(
|
|
progressEvents.map((p) => [Number(p.stopOrder), p]),
|
|
);
|
|
const latestProgress =
|
|
progressEvents.length > 0
|
|
? progressEvents[progressEvents.length - 1]
|
|
: null;
|
|
const currentDelayMinutes = latestProgress
|
|
? Number(latestProgress.delayMinutes) || 0
|
|
: 0;
|
|
|
|
const occurrenceDateKey =
|
|
req.query.date ||
|
|
req.query.occurrenceDate ||
|
|
toDateKey(schedule.startTime);
|
|
const stops = routeStops.map((rs) => {
|
|
const stop = (_mock.stops || []).find(
|
|
(s) => Number(s.id) === Number(rs.stopId),
|
|
);
|
|
const templateStart = new Date(schedule.startTime);
|
|
const scheduledTime = new Date(`${occurrenceDateKey}T00:00:00`);
|
|
scheduledTime.setHours(
|
|
templateStart.getHours(),
|
|
templateStart.getMinutes(),
|
|
templateStart.getSeconds(),
|
|
templateStart.getMilliseconds(),
|
|
);
|
|
scheduledTime.setMinutes(
|
|
scheduledTime.getMinutes() +
|
|
(Number(rs.estimatedArrivalMinutes) || 0),
|
|
);
|
|
const progress = progressByOrder.get(Number(rs.stopOrder));
|
|
|
|
return {
|
|
stopId: Number(rs.stopId),
|
|
stopOrder: Number(rs.stopOrder),
|
|
stopName: stop?.name || rs.name || "Unknown Stop",
|
|
estimatedArrivalMinutes: Number(rs.estimatedArrivalMinutes) || 0,
|
|
scheduledPassedAt: scheduledTime.toISOString(),
|
|
actualPassedAt: progress?.actualPassedAt || null,
|
|
delayMinutes: progress ? Number(progress.delayMinutes) || 0 : null,
|
|
status: progress ? "passed" : "pending",
|
|
};
|
|
});
|
|
|
|
const passedStops = stops.filter((s) => s.status === "passed");
|
|
const nextStop = stops.find((s) => s.status === "pending") || null;
|
|
const driverState =
|
|
passedStops.length === 0
|
|
? "not_started"
|
|
: passedStops.length >= stops.length
|
|
? "completed"
|
|
: "en_route";
|
|
|
|
return res.json({
|
|
schedule: {
|
|
id: scheduleId,
|
|
lineNumber: Number(busLine?.lineNumber || 0),
|
|
startStop: busLine?.startStop || null,
|
|
endStop: busLine?.endStop || null,
|
|
startTime: schedule.startTime,
|
|
endTime: schedule.endTime,
|
|
status: schedule.status,
|
|
},
|
|
driverState,
|
|
currentDelayMinutes,
|
|
passedStopCount: passedStops.length,
|
|
totalStopCount: stops.length,
|
|
nextStop: nextStop
|
|
? {
|
|
...nextStop,
|
|
predictedPassedAt: new Date(
|
|
new Date(nextStop.scheduledPassedAt).getTime() +
|
|
currentDelayMinutes * 60000,
|
|
).toISOString(),
|
|
}
|
|
: null,
|
|
stops,
|
|
updatedAt: latestProgress?.actualPassedAt || null,
|
|
});
|
|
}
|
|
|
|
await ensureScheduleStopProgressTable();
|
|
|
|
const scheduleResult = await db.query(
|
|
`
|
|
SELECT
|
|
s.id,
|
|
s.bus_line_id,
|
|
s.start_time,
|
|
s.end_time,
|
|
s.status,
|
|
bl.line_number,
|
|
bl.start_stop,
|
|
bl.end_stop
|
|
FROM transit.schedules s
|
|
JOIN transit.bus_lines bl ON bl.id = s.bus_line_id
|
|
WHERE s.id = $1
|
|
LIMIT 1
|
|
`,
|
|
[scheduleId],
|
|
);
|
|
|
|
if (scheduleResult.rows.length === 0) {
|
|
return res.status(404).json({ error: "schedule_not_found" });
|
|
}
|
|
|
|
const schedule = scheduleResult.rows[0];
|
|
|
|
const routeStopsResult = await db.query(
|
|
`
|
|
SELECT
|
|
rs.stop_id,
|
|
rs.stop_order,
|
|
rs.estimated_arrival_minutes,
|
|
s.name AS stop_name
|
|
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
|
|
`,
|
|
[schedule.bus_line_id],
|
|
);
|
|
|
|
// Allow querying progress for a specific occurrence date (recurring templates)
|
|
const progressDate = req.query.date || req.query.occurrenceDate || null;
|
|
let progressResult;
|
|
if (progressDate) {
|
|
progressResult = await db.query(
|
|
`
|
|
SELECT
|
|
stop_order,
|
|
actual_passed_at,
|
|
scheduled_passed_at,
|
|
scheduled_passed_date
|
|
FROM transit.schedule_stop_progress
|
|
WHERE schedule_id = $1 AND scheduled_passed_date = $2
|
|
ORDER BY stop_order ASC
|
|
`,
|
|
[scheduleId, progressDate],
|
|
);
|
|
} else {
|
|
progressResult = await db.query(
|
|
`
|
|
SELECT
|
|
stop_order,
|
|
actual_passed_at,
|
|
scheduled_passed_at,
|
|
scheduled_passed_date
|
|
FROM transit.schedule_stop_progress
|
|
WHERE schedule_id = $1
|
|
ORDER BY scheduled_passed_at ASC, stop_order ASC
|
|
`,
|
|
[scheduleId],
|
|
);
|
|
}
|
|
|
|
const progressByOrder = new Map(
|
|
progressResult.rows.map((row) => [Number(row.stop_order), row]),
|
|
);
|
|
const latestProgress =
|
|
progressResult.rows.length > 0
|
|
? progressResult.rows[progressResult.rows.length - 1]
|
|
: null;
|
|
|
|
// Calculate delay live from latest passed stop
|
|
let currentDelayMinutes = null;
|
|
if (
|
|
latestProgress &&
|
|
latestProgress.actual_passed_at &&
|
|
latestProgress.scheduled_passed_at
|
|
) {
|
|
const actualTime = new Date(latestProgress.actual_passed_at);
|
|
const scheduledTime = new Date(latestProgress.scheduled_passed_at);
|
|
currentDelayMinutes = Math.round(
|
|
(actualTime.getTime() - scheduledTime.getTime()) / 60000,
|
|
);
|
|
}
|
|
|
|
const occurrenceDateKeyForMapping =
|
|
progressDate || toDateKey(schedule.start_time);
|
|
|
|
const stops = routeStopsResult.rows.map((row) => {
|
|
const etaMinutes = Number(row.estimated_arrival_minutes) || 0;
|
|
const templateStart = new Date(schedule.start_time);
|
|
const scheduledPassedAt = new Date(
|
|
`${occurrenceDateKeyForMapping}T00:00:00`,
|
|
);
|
|
scheduledPassedAt.setHours(
|
|
templateStart.getHours(),
|
|
templateStart.getMinutes(),
|
|
templateStart.getSeconds(),
|
|
templateStart.getMilliseconds(),
|
|
);
|
|
scheduledPassedAt.setMinutes(scheduledPassedAt.getMinutes() + etaMinutes);
|
|
|
|
const progress = progressByOrder.get(Number(row.stop_order));
|
|
|
|
return {
|
|
stopId: Number(row.stop_id),
|
|
stopOrder: Number(row.stop_order),
|
|
stopName: row.stop_name,
|
|
estimatedArrivalMinutes: etaMinutes,
|
|
scheduledPassedAt: scheduledPassedAt.toISOString(),
|
|
actualPassedAt: progress?.actual_passed_at || null,
|
|
delayMinutes: (() => {
|
|
if (progress?.actual_passed_at && progress?.scheduled_passed_at) {
|
|
const actualTime = new Date(progress.actual_passed_at);
|
|
const scheduledTime = new Date(progress.scheduled_passed_at);
|
|
return Math.round(
|
|
(actualTime.getTime() - scheduledTime.getTime()) / 60000,
|
|
);
|
|
}
|
|
return null;
|
|
})(),
|
|
status: progress ? "passed" : "pending",
|
|
};
|
|
});
|
|
|
|
const passedStops = stops.filter((s) => s.status === "passed");
|
|
const nextStop = stops.find((s) => s.status === "pending") || null;
|
|
|
|
const driverState =
|
|
passedStops.length === 0
|
|
? "not_started"
|
|
: passedStops.length >= stops.length
|
|
? "completed"
|
|
: "en_route";
|
|
|
|
res.json({
|
|
schedule: {
|
|
id: Number(schedule.id),
|
|
lineNumber: Number(schedule.line_number),
|
|
startStop: schedule.start_stop,
|
|
endStop: schedule.end_stop,
|
|
startTime: schedule.start_time,
|
|
endTime: schedule.end_time,
|
|
status: schedule.status,
|
|
},
|
|
driverState,
|
|
currentDelayMinutes,
|
|
passedStopCount: passedStops.length,
|
|
totalStopCount: stops.length,
|
|
nextStop: nextStop
|
|
? {
|
|
...nextStop,
|
|
predictedPassedAt: new Date(
|
|
new Date(nextStop.scheduledPassedAt).getTime() +
|
|
currentDelayMinutes * 60000,
|
|
).toISOString(),
|
|
}
|
|
: null,
|
|
stops,
|
|
updatedAt: latestProgress?.actual_passed_at || null,
|
|
});
|
|
} catch (err) {
|
|
console.error("GET /public/schedules/:scheduleId/progress error:", err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// ADMIN - Check driver conflicts for generated schedules
|
|
// ============================================
|
|
app.post(
|
|
"/admin/check-driver-conflicts",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const {
|
|
generatedTimes,
|
|
drivers,
|
|
busLineId,
|
|
busId,
|
|
weekdays = [],
|
|
} = req.body;
|
|
|
|
if (!Array.isArray(generatedTimes) || !Array.isArray(drivers)) {
|
|
return res.status(400).json({ error: "missing_required_fields" });
|
|
}
|
|
|
|
// Get all existing schedules
|
|
const schedResult = DEV_MOCK
|
|
? { rows: _mock.schedules || [] }
|
|
: await db.query(
|
|
`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`,
|
|
);
|
|
|
|
const existingSchedules = schedResult.rows.map((row) => ({
|
|
...row,
|
|
weekdays: parseWeekdaysValue(row.weekdays || row.weekday_list || "[]"),
|
|
}));
|
|
|
|
// For each generated time, check conflicts with each driver
|
|
const assignments = generatedTimes.map((timeStr) => {
|
|
const driverAssignments = drivers.map((driver) => {
|
|
const hasConflict = checkDriverConflict(
|
|
driver.id,
|
|
timeStr,
|
|
timeStr, // Will be checked with start/end times of conflicts
|
|
weekdays,
|
|
existingSchedules,
|
|
);
|
|
|
|
return {
|
|
driverId: driver.id,
|
|
driverName: driver.name,
|
|
hasConflict,
|
|
assigned: false,
|
|
};
|
|
});
|
|
|
|
return {
|
|
time: timeStr,
|
|
driverAssignments,
|
|
assignedDriverId: null,
|
|
};
|
|
});
|
|
|
|
res.json({ assignments });
|
|
} catch (err) {
|
|
console.error("/admin/check-driver-conflicts error:", err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
// ============================================
|
|
// ADMIN - Auto-assign schedules to drivers
|
|
// ============================================
|
|
app.post(
|
|
"/admin/assign-schedules",
|
|
authMiddleware,
|
|
adminMiddleware,
|
|
async (req, res) => {
|
|
try {
|
|
const { assignments, busLineId, busId, weekdays = [] } = req.body;
|
|
|
|
if (!Array.isArray(assignments)) {
|
|
return res.status(400).json({ error: "missing_assignments" });
|
|
}
|
|
|
|
// Group assignments by driver (round-robin or custom)
|
|
const schedulesToCreate = [];
|
|
let driverIndex = 0;
|
|
|
|
for (const assignment of assignments) {
|
|
if (!assignment.time || !assignment.assignedDriverId) continue;
|
|
|
|
const startTime = assignment.time;
|
|
const endTime = assignment.endTime;
|
|
|
|
schedulesToCreate.push({
|
|
busLineId,
|
|
busId,
|
|
driverId: assignment.assignedDriverId,
|
|
startTime,
|
|
endTime,
|
|
weekdays,
|
|
});
|
|
}
|
|
|
|
// Use bulk create endpoint logic
|
|
if (DEV_MOCK) {
|
|
if (!_mock.schedules) _mock.schedules = [];
|
|
const createdSchedules = [];
|
|
|
|
for (const entry of schedulesToCreate) {
|
|
const schedule = {
|
|
id: _mock.nextScheduleId || 1,
|
|
...entry,
|
|
status: "planned",
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
_mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1;
|
|
_mock.schedules.push(schedule);
|
|
createdSchedules.push(schedule);
|
|
}
|
|
|
|
return res.status(201).json({
|
|
createdCount: createdSchedules.length,
|
|
schedules: createdSchedules,
|
|
});
|
|
}
|
|
|
|
// For DB: use bulk insert
|
|
res.status(201).json({
|
|
createdCount: schedulesToCreate.length,
|
|
message: "would_insert_schedules",
|
|
});
|
|
} catch (err) {
|
|
console.error("/admin/assign-schedules error:", err);
|
|
res.status(500).json({ error: "internal_error", details: err.message });
|
|
}
|
|
},
|
|
);
|
|
|
|
const PORT = process.env.PORT || 5001;
|
|
|
|
// Initialize database tables on startup
|
|
(async () => {
|
|
try {
|
|
await ensureScheduleStopProgressTable();
|
|
console.log("✓ Database tables initialized");
|
|
} catch (err) {
|
|
console.error("⚠ Error initializing tables:", err.message);
|
|
}
|
|
|
|
app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));
|
|
})();
|
|
|
|
module.exports = app;
|