7d55580d87
Split the 5,441-line index.js into an 86-line bootstrap, thin domain
route modules, shared lib/ helpers, and a repo/ layer that isolates the
DEV_MOCK branches (repo/*.mock.js). Repo methods return {statusCode, body};
routes are one-line wrappers. Pin search_path to "transit.bus" and
document the /bus/ prefix with legacy root aliases (Deprecation header).
Parity verified: 57/57 byte-identical on the stub differential harness
plus 24/24 live read-only responses vs the original against real
PostgreSQL. The only intentional behavior change: mock reorder/delete
bus-line endpoints no longer require a live DB (previously 500 in
DEV_MOCK=1 without PG). DEV_MOCK gate and node --check clean.
545 lines
18 KiB
JavaScript
545 lines
18 KiB
JavaScript
const { store: _mock } = require("../mock/store");
|
||
const { toDateKey, expandRecurringScheduleRows, scheduleMatchesFilters, parseScheduleFilters, mapRecurringOccurrenceRows, materializeScheduleOverviewRow } = require("../lib/schedule");
|
||
const { reportError } = require("../lib/report-error");
|
||
|
||
// Schedules repository methods (DEV_MOCK mode). Exact copies of the original
|
||
// handlers’ mock branches, returning { statusCode, body }. Note: the markStopPassed
|
||
// mock branch reproduces the original ReferenceError (it reads an undeclared
|
||
// `delayMinutes`), so it always returns 500 in mock mode, exactly as before.
|
||
|
||
async function listSchedules(req) {
|
||
try {
|
||
const filters = parseScheduleFilters(req.query, {
|
||
statuses: ["planned", "active"],
|
||
});
|
||
|
||
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 { statusCode: 200, body: { schedules } };
|
||
} catch (err) {
|
||
console.error(err);
|
||
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
||
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
||
}
|
||
}
|
||
|
||
async function getSchedulesOverview(req) {
|
||
try {
|
||
const filters = parseScheduleFilters(req.query, {
|
||
statuses: ["planned", "active"],
|
||
});
|
||
const overviewDateKey = filters.date || null;
|
||
|
||
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 {
|
||
statusCode: 200,
|
||
body: {
|
||
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);
|
||
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
||
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
||
}
|
||
}
|
||
|
||
async function listRecurringSchedules(req) {
|
||
try {
|
||
const filters = parseScheduleFilters(req.query);
|
||
|
||
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 { statusCode: 200, body: { schedules } };
|
||
} catch (err) {
|
||
console.error(err);
|
||
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
||
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
||
}
|
||
}
|
||
|
||
async function getScheduleById(req) {
|
||
try {
|
||
const { scheduleId } = req.params;
|
||
|
||
const schedule = (_mock.schedules || []).find(
|
||
(s) => Number(s.id) === Number(scheduleId),
|
||
);
|
||
if (!schedule) {
|
||
return { statusCode: 404, body: { error: "schedule_not_found" } };
|
||
}
|
||
|
||
const busLine = _mock.busLines.find((bl) => bl.id === schedule.busLineId);
|
||
const bus = _mock.fleetBuses.find((b) => b.id === schedule.busId);
|
||
|
||
return {
|
||
statusCode: 200,
|
||
body: {
|
||
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",
|
||
},
|
||
},
|
||
};
|
||
} catch (err) {
|
||
console.error(err);
|
||
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
||
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
||
}
|
||
}
|
||
|
||
async function getScheduleStops(req) {
|
||
try {
|
||
const { scheduleId } = req.params;
|
||
|
||
const schedule = (_mock.schedules || []).find(
|
||
(s) => Number(s.id) === Number(scheduleId),
|
||
);
|
||
if (!schedule) {
|
||
return { statusCode: 404, body: { error: "schedule_not_found" } };
|
||
}
|
||
const routeStops = (_mock.routeStops || [])
|
||
.filter((rs) => Number(rs.busLineId) === Number(schedule.busLineId))
|
||
.sort((a, b) => a.stopOrder - b.stopOrder);
|
||
|
||
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 { statusCode: 200, body: { stops: formattedStops } };
|
||
} catch (err) {
|
||
console.error(err);
|
||
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
||
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
||
}
|
||
}
|
||
|
||
async function markStopPassed(req) {
|
||
try {
|
||
const scheduleId = Number(req.params.scheduleId);
|
||
const stopOrder = Number(req.params.stopOrder);
|
||
|
||
if (
|
||
!Number.isFinite(scheduleId) ||
|
||
!Number.isFinite(stopOrder) ||
|
||
stopOrder < 1
|
||
) {
|
||
return {
|
||
statusCode: 400,
|
||
body: { error: "invalid_schedule_or_stop_order" },
|
||
};
|
||
}
|
||
|
||
const actualPassedAt = req.body?.actualPassedAt
|
||
? new Date(req.body.actualPassedAt)
|
||
: new Date();
|
||
if (Number.isNaN(actualPassedAt.getTime())) {
|
||
return { statusCode: 400, body: { error: "invalid_actual_passed_at" } };
|
||
}
|
||
|
||
const schedule = (_mock.schedules || []).find(
|
||
(s) => Number(s.id) === scheduleId,
|
||
);
|
||
if (!schedule) {
|
||
return { statusCode: 404, body: { error: "schedule_not_found" } };
|
||
}
|
||
|
||
const routeStop = (_mock.routeStops || []).find(
|
||
(rs) =>
|
||
Number(rs.busLineId) === Number(schedule.busLineId) &&
|
||
Number(rs.stopOrder) === stopOrder,
|
||
);
|
||
|
||
if (!routeStop) {
|
||
return { statusCode: 404, body: { error: "stop_not_found_for_schedule" } };
|
||
}
|
||
|
||
const stop = (_mock.stops || []).find(
|
||
(s) => Number(s.id) === Number(routeStop.stopId),
|
||
);
|
||
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 {
|
||
statusCode: 200,
|
||
body: {
|
||
scheduleId,
|
||
stopOrder,
|
||
status: schedule.status,
|
||
delayMinutes,
|
||
passedStops: passedCount,
|
||
totalStops,
|
||
occurrenceDate: occurrenceDateKey,
|
||
},
|
||
};
|
||
} catch (err) {
|
||
console.error(
|
||
"POST /driver/schedules/:scheduleId/stops/:stopOrder/passed error:",
|
||
err,
|
||
);
|
||
reportError({ partofservice: "POST /driver/schedules/:scheduleId/stops/:stopOrder/passed", error: err.stack || err.message });
|
||
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
||
}
|
||
}
|
||
|
||
async function getPublicScheduleProgress(req) {
|
||
try {
|
||
const scheduleId = Number(req.params.scheduleId);
|
||
if (!Number.isFinite(scheduleId)) {
|
||
return { statusCode: 400, body: { error: "invalid_schedule_id" } };
|
||
}
|
||
|
||
const schedule = (_mock.schedules || []).find(
|
||
(s) => Number(s.id) === scheduleId,
|
||
);
|
||
if (!schedule) {
|
||
return { statusCode: 404, body: { 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 {
|
||
statusCode: 200,
|
||
body: {
|
||
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,
|
||
},
|
||
};
|
||
} catch (err) {
|
||
console.error("GET /public/schedules/:scheduleId/progress error:", err);
|
||
reportError({ partofservice: "GET /public/schedules/:scheduleId/progress", error: err.stack || err.message });
|
||
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
listSchedules,
|
||
getSchedulesOverview,
|
||
listRecurringSchedules,
|
||
getScheduleById,
|
||
getScheduleStops,
|
||
markStopPassed,
|
||
getPublicScheduleProgress,
|
||
}; |