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.
1106 lines
33 KiB
JavaScript
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,
|
|
}; |