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

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,
};