Files
Sven laptop 7d55580d87 Refactor transit_api into a repo/routes module split
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.
2026-09-24 20:18:32 +02:00

1106 lines
33 KiB
JavaScript

const db = require("../db");
const { DEV_MOCK } = require("../lib/env");
const { parseWeekdaysValue, toDateKey, expandRecurringScheduleRows, scheduleMatchesFilters, parseScheduleFilters, scheduleOccursOnDate, materializeScheduleOverviewRow, mapRecurringOccurrenceRows, materializeScheduleRowsInRange } = require("../lib/schedule");
const { reportError } = require("../lib/report-error");
const { ensureScheduleStopProgressTable } = require("../lib/schema");
// Schedules repository methods (PostgreSQL mode). Every method returns
// { statusCode, body } mirroring the original handler's DB branch.
async function updateDriverScheduleStatus(req) {
try {
const { scheduleId } = req.params;
const { status } = req.body;
const validStatuses = [
"planned",
"in_progress",
"completed",
"cancelled",
"active",
];
if (!validStatuses.includes(status)) {
return { statusCode: 400, body: { error: "invalid_status" } };
}
const normalizedStatus = status === "active" ? "in_progress" : status;
const existing = await db.query(
`SELECT * FROM schedules WHERE id = $1`,
[scheduleId],
);
if (existing.rows.length === 0) {
return { statusCode: 404, body: { error: "schedule_not_found" } };
}
const scheduleRow = existing.rows[0];
const weekdays = parseWeekdaysValue(scheduleRow.weekdays);
const requestedDate = req.body.date || null;
if (weekdays && weekdays.length > 0) {
let targetDateKey = requestedDate;
if (!targetDateKey) {
const todayKey = new Date().toISOString().slice(0, 10);
if (scheduleOccursOnDate(scheduleRow, todayKey)) {
targetDateKey = todayKey;
}
}
if (targetDateKey) {
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);
}
const existingInstance = await db.query(
`SELECT * FROM schedules WHERE template_id = $1 AND DATE(start_time) = $2`,
[scheduleId, targetDateKey],
);
if (existingInstance.rows.length > 0) {
const upd = await db.query(
`
UPDATE schedules SET status = $1, updated_at = NOW()
WHERE id = $2
RETURNING *
`,
[normalizedStatus, existingInstance.rows[0].id],
);
return { statusCode: 200, body: { schedule: upd.rows[0] } };
}
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 schedules)
INSERT INTO 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 schedules)
INSERT INTO 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 { statusCode: 200, body: { schedule: insertResult.rows[0] } };
}
}
const result = await db.query(
`
UPDATE schedules
SET status = $1, updated_at = NOW()
WHERE id = $2
RETURNING *
`,
[normalizedStatus, scheduleId],
);
if (result.rows.length === 0) {
return { statusCode: 404, body: { error: "schedule_not_found" } };
}
return { statusCode: 200, body: { schedule: result.rows[0] } };
} 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 listSchedules(req) {
try {
const filters = parseScheduleFilters(req.query, {
statuses: ["planned", "active"],
});
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 schedules s
LEFT JOIN 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 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),
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,
}));
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 hasWeekdaysColumn = await db.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'transit.bus' 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 schedules s
LEFT JOIN 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 schedules s
LEFT JOIN 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 route_stops rs
JOIN 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));
}
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 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,
};
});
}
}
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 hasWeekdaysColumn = await db.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'transit.bus' AND table_name = 'schedules' AND column_name = 'weekdays'
`);
if (hasWeekdaysColumn.rows.length === 0) {
return { statusCode: 200, body: { 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 schedules s
LEFT JOIN 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 route_stops rs
JOIN 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;
});
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 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 schedules s
LEFT JOIN 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 { statusCode: 404, body: { error: "schedule_not_found" } };
}
const row = result.rows[0];
const weekdays = parseWeekdaysValue(row.weekdays);
return {
statusCode: 200,
body: {
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);
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 scheduleResult = await db.query(
`SELECT bus_line_id FROM schedules WHERE id = $1`,
[scheduleId],
);
if (scheduleResult.rows.length === 0) {
return { statusCode: 404, body: { 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 route_stops rs
JOIN stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = $1
ORDER BY rs.stop_order ASC
`,
[busLineId],
);
return {
statusCode: 200,
body: {
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);
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" } };
}
await ensureScheduleStopProgressTable();
const scheduleResult = await db.query(
`
SELECT s.id, s.bus_line_id, s.start_time, s.status
FROM schedules s
WHERE s.id = $1
`,
[scheduleId],
);
if (scheduleResult.rows.length === 0) {
return { statusCode: 404, body: { 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 route_stops rs
JOIN 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 { statusCode: 404, body: { error: "stop_not_found_for_schedule" } };
}
const routeStop = routeStopResult.rows[0];
let occurrenceDateKey =
req.query.date || req.body.date || toDateKey(schedule.start_time);
const weekdays = parseWeekdaysValue(schedule.weekdays);
let instanceScheduleId = scheduleId;
if (weekdays && weekdays.length > 0) {
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 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 {
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 schedules)
INSERT INTO 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 schedules)
INSERT INTO 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 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 route_stops WHERE bus_line_id = $1) AS total_stops,
(SELECT COUNT(*)::int FROM 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 schedules
SET
status = $2,
end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END
WHERE id = $1
`,
[instanceScheduleId, nextStatus, actualPassedAt.toISOString()],
);
const delayMinutes = Math.round(
(actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000,
);
return {
statusCode: 200,
body: {
scheduleId,
stopOrder,
status: nextStatus,
delayMinutes,
passedStops,
totalStops,
},
};
} 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" } };
}
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 schedules s
JOIN bus_lines bl ON bl.id = s.bus_line_id
WHERE s.id = $1
LIMIT 1
`,
[scheduleId],
);
if (scheduleResult.rows.length === 0) {
return { statusCode: 404, body: { 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 route_stops rs
JOIN stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = $1
ORDER BY rs.stop_order ASC
`,
[schedule.bus_line_id],
);
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 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 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;
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";
return {
statusCode: 200,
body: {
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);
reportError({ partofservice: "GET /public/schedules/:scheduleId/progress", error: err.stack || err.message });
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
}
}
module.exports = {
updateDriverScheduleStatus,
listSchedules,
getSchedulesOverview,
listRecurringSchedules,
getScheduleById,
getScheduleStops,
markStopPassed,
getPublicScheduleProgress,
};