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.
478 lines
14 KiB
JavaScript
478 lines
14 KiB
JavaScript
const db = require("../db");
|
|
const { reportError } = require("../lib/report-error");
|
|
|
|
// Bus-line repository methods (PostgreSQL mode).
|
|
// The client-based methods keep their original `db.pool.connect()` call before
|
|
// the try block, preserving the exact original control flow (including the
|
|
// connection/rollback/release dance) for byte-identical behavior.
|
|
|
|
async function listLineStops(req) {
|
|
try {
|
|
const { busLineId } = req.params;
|
|
|
|
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 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],
|
|
);
|
|
|
|
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,
|
|
}));
|
|
|
|
return { statusCode: 200, body: { stops } };
|
|
} 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 listLines(req) {
|
|
try {
|
|
const result = await db.query(`
|
|
SELECT id, line_number, start_stop, end_stop, estimated_duration_minutes, description
|
|
FROM bus_lines
|
|
ORDER BY line_number ASC, id ASC
|
|
`);
|
|
|
|
return {
|
|
statusCode: 200,
|
|
body: {
|
|
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);
|
|
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
|
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
|
}
|
|
}
|
|
|
|
async function createLine(req) {
|
|
try {
|
|
const {
|
|
lineNumber,
|
|
startStop,
|
|
endStop,
|
|
estimatedDurationMinutes,
|
|
description,
|
|
} = req.body;
|
|
|
|
if (
|
|
lineNumber === undefined ||
|
|
!startStop ||
|
|
!endStop ||
|
|
estimatedDurationMinutes === undefined
|
|
) {
|
|
return { statusCode: 400, body: { error: "missing_required_fields" } };
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
WITH next_id AS (
|
|
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM bus_lines
|
|
)
|
|
INSERT INTO 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];
|
|
return {
|
|
statusCode: 201,
|
|
body: {
|
|
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);
|
|
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
|
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
|
}
|
|
}
|
|
|
|
async function updateLine(req) {
|
|
try {
|
|
const { busLineId } = req.params;
|
|
const {
|
|
lineNumber,
|
|
startStop,
|
|
endStop,
|
|
estimatedDurationMinutes,
|
|
description,
|
|
} = req.body;
|
|
|
|
const result = await db.query(
|
|
`
|
|
UPDATE 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 { statusCode: 404, body: { error: "bus_line_not_found" } };
|
|
|
|
const busLine = result.rows[0];
|
|
return {
|
|
statusCode: 200,
|
|
body: {
|
|
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);
|
|
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
|
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
|
}
|
|
}
|
|
|
|
async function listLineStopsDetailed(req) {
|
|
try {
|
|
const { busLineId } = req.params;
|
|
if (!busLineId || !Number.isFinite(Number(busLineId))) {
|
|
return { statusCode: 400, body: { error: "invalid_bus_line_id" } };
|
|
}
|
|
|
|
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 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: {
|
|
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);
|
|
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
|
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
|
}
|
|
}
|
|
|
|
async function addLineStop(req) {
|
|
try {
|
|
const { busLineId } = req.params;
|
|
const { stopId, stopOrder, estimatedArrivalMinutes } = req.body;
|
|
|
|
if (!stopId || stopOrder === undefined) {
|
|
return { statusCode: 400, body: { error: "missing_required_fields" } };
|
|
}
|
|
|
|
const result = await db.query(
|
|
`
|
|
WITH next_id AS (
|
|
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM route_stops
|
|
)
|
|
INSERT INTO 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];
|
|
return {
|
|
statusCode: 201,
|
|
body: {
|
|
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);
|
|
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
|
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
|
}
|
|
}
|
|
|
|
async function reorderLineStops(req) {
|
|
const client = await db.pool.connect();
|
|
try {
|
|
const { busLineId } = req.params;
|
|
const { routeStopIds } = req.body || {};
|
|
|
|
if (!Array.isArray(routeStopIds) || routeStopIds.length === 0) {
|
|
return { statusCode: 400, body: { error: "routeStopIds array required" } };
|
|
}
|
|
|
|
const normalizedIds = routeStopIds
|
|
.map((id) => Number(id))
|
|
.filter((id) => Number.isFinite(id));
|
|
if (normalizedIds.length !== routeStopIds.length) {
|
|
return { statusCode: 400, body: { error: "routeStopIds must contain only numeric ids" } };
|
|
}
|
|
|
|
const current = await client.query(
|
|
`SELECT id FROM 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 { statusCode: 400, body: { 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 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 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 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],
|
|
);
|
|
|
|
await client.query("COMMIT");
|
|
|
|
return {
|
|
statusCode: 200,
|
|
body: {
|
|
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);
|
|
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
|
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async function deleteLine(req) {
|
|
const client = await db.pool.connect();
|
|
try {
|
|
const { busLineId } = req.params;
|
|
|
|
if (!busLineId || !Number.isFinite(Number(busLineId))) {
|
|
return { statusCode: 400, body: { error: "invalid_bus_line_id" } };
|
|
}
|
|
|
|
await client.query("BEGIN");
|
|
|
|
// Delete associated route_stops first
|
|
await client.query(
|
|
`DELETE FROM route_stops WHERE bus_line_id = $1`,
|
|
[busLineId],
|
|
);
|
|
|
|
// Delete the bus line
|
|
const result = await client.query(
|
|
`DELETE FROM bus_lines WHERE id = $1 RETURNING id, line_number`,
|
|
[busLineId],
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
await client.query("ROLLBACK");
|
|
return { statusCode: 404, body: { error: "bus_line_not_found" } };
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
return { statusCode: 200, body: { success: true, busLine: result.rows[0] } };
|
|
} catch (err) {
|
|
await client.query("ROLLBACK").catch(() => {});
|
|
console.error(err);
|
|
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
|
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async function deleteLineStop(req) {
|
|
const client = await db.pool.connect();
|
|
try {
|
|
const { busLineId, routeStopId } = req.params;
|
|
|
|
if (!busLineId || !Number.isFinite(Number(busLineId))) {
|
|
return { statusCode: 400, body: { error: "invalid_bus_line_id" } };
|
|
}
|
|
|
|
if (!routeStopId || !Number.isFinite(Number(routeStopId))) {
|
|
return { statusCode: 400, body: { error: "invalid_route_stop_id" } };
|
|
}
|
|
|
|
await client.query("BEGIN");
|
|
|
|
// Find and delete the route_stop
|
|
const result = await client.query(
|
|
`DELETE FROM 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 { statusCode: 404, body: { error: "route_stop_not_found" } };
|
|
}
|
|
|
|
const deletedOrder = result.rows[0].stop_order;
|
|
|
|
// Re-order remaining stops
|
|
await client.query(
|
|
`UPDATE 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");
|
|
return { statusCode: 200, body: { success: true, routeStop: result.rows[0] } };
|
|
} catch (err) {
|
|
await client.query("ROLLBACK").catch(() => {});
|
|
console.error(err);
|
|
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
|
|
return { statusCode: 500, body: { error: "internal_error", details: err.message } };
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
listLineStops,
|
|
listLines,
|
|
createLine,
|
|
updateLine,
|
|
listLineStopsDetailed,
|
|
addLineStop,
|
|
reorderLineStops,
|
|
deleteLine,
|
|
deleteLineStop,
|
|
}; |