diff --git a/admin_ui/README.md b/admin_ui/README.md index d034bd0..7922d7c 100644 --- a/admin_ui/README.md +++ b/admin_ui/README.md @@ -1,3 +1,18 @@ +## Weekly schedules and shifts + +The schedule manager uses the weekly template model. Administrators create a +departure for a bus line in one of three categories: weekdays, Saturday, or +Sunday. The UI creates the corresponding `day_of_week` rows and assigns drivers +through `transit.shifts`, rather than storing a driver on `transit.schedules`. + +The active UI uses these transit API endpoints: + +- `GET /admin/weekly-schedules` +- `GET /admin/shift-drivers` +- `POST /admin/weekly-schedules` +- `PUT /admin/weekly-schedules/:scheduleId/shift` +- `DELETE /admin/weekly-schedules/:scheduleId` + # Transit Admin Control Center Modern React + Vite web application for comprehensive transit system administration. @@ -15,6 +30,7 @@ Modern React + Vite web application for comprehensive transit system administrat ## Local Development ### Prerequisites + - Node.js 18+ - npm or yarn @@ -86,9 +102,9 @@ http://localhost:5174 ### Environment Variables -| Variable | Description | Default | -|----------|-------------|---------| -| `VITE_AUTH_API` | Auth API URL (same-origin path recommended in Docker) | http://localhost:5000 | +| Variable | Description | Default | +| ------------------ | -------------------------------------------------------- | --------------------- | +| `VITE_AUTH_API` | Auth API URL (same-origin path recommended in Docker) | http://localhost:5000 | | `VITE_TRANSIT_API` | Transit API URL (same-origin path recommended in Docker) | http://localhost:5001 | ### Runtime Configuration @@ -98,6 +114,7 @@ When running in Docker, environment variables are injected at runtime via `docke ## Architecture ### Technology Stack + - **Frontend**: React 18.2 + Vite 5.0 - **HTTP Client**: Axios 1.4 - **Server**: Nginx (Alpine Linux) @@ -116,6 +133,7 @@ When running in Docker, environment variables are injected at runtime via `docke ### API Integration The app communicates with: + - **Auth API** (/login, /me) - **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses) - **Error Log API** (/error_log) @@ -148,16 +166,19 @@ Users must have a `userCode` in their JWT and, for admin access, the `isAdmin` f ## Troubleshooting ### Issue: "Admin UI not loading" + - Verify container is running: `docker ps` - Check logs: `docker-compose logs admin_ui` - Clear browser cache (Ctrl+Shift+R) ### Issue: "API connection refused" + - Ensure auth_api and transit_api are running - Check environment variables are set correctly - Verify network connectivity between containers ### Issue: "Permission denied" on admin operations + - Verify JWT token includes valid `userCode` - Check `ADMIN_USER_CODES` environment variable - Ensure database user has appropriate permissions @@ -180,6 +201,7 @@ See [DEPLOYMENT.md](../DEPLOYMENT.md) for comprehensive deployment guide. ## Support For issues or questions: + 1. Check service logs: `docker-compose logs admin_ui` 2. Verify API connectivity: Visit API URLs directly 3. Check browser console for client-side errors @@ -188,4 +210,3 @@ For issues or questions: ## License Part of the XTransit project - diff --git a/admin_ui/SCHEDULE_API.md b/admin_ui/SCHEDULE_API.md index 4f9ff46..4415cbe 100644 --- a/admin_ui/SCHEDULE_API.md +++ b/admin_ui/SCHEDULE_API.md @@ -4,66 +4,149 @@ The Transit API now includes a comprehensive schedule management system with both admin and public endpoints. -### Admin Endpoints (requires authentication + admin privileges) +### Weekly Admin Endpoints (requires authentication + admin privileges) + +The active admin UI uses weekly schedule templates. `dayOfWeek` follows the +JavaScript/PostgreSQL convention: `0` Sunday, `1` through `5` weekdays, and `6` +Saturday. Driver assignment is stored in `transit.shifts` and references +`workers.users(id)`. + +#### Get weekly schedules -#### Get all schedules ``` -GET /admin/schedules +GET /admin/weekly-schedules Response: { schedules: [...] } ``` -#### Create a schedule +#### Get shift drivers + ``` +GET /admin/shift-drivers +Response: { drivers: [{ id, name }] } +``` + +#### Create a weekly schedule + +```` + +#### Create multiple departures + +```text +POST /admin/weekly-schedules/bulk +Body: { + busLineId: 1, + dayOfWeeks: [1, 2, 3, 4, 5], + startTimes: ["06:00", "06:30", "07:00"], + driverId: 12 +} +```` + +The API creates one schedule row for every day/time combination and derives the +end time from the bus line's configured duration. +POST /admin/weekly-schedules +Body: { +busLineId: 1, +dayOfWeek: 1, +startTime: "08:00", +endTime: "09:00", +driverId: 12 +} + +``` + +#### Replace a schedule's driver + +``` + +PUT /admin/weekly-schedules/:scheduleId/shift +Body: { driverId: 12 } + +``` + +Use `{ "driverId": null }` to unassign the driver. The current `shifts` table +does not contain a week/date column, so this is an active assignment rather than +historical per-week assignment. Add `week_start` to support that later. + +### Legacy Admin Endpoints + +#### Get all schedules + +``` + +GET /admin/schedules +Response: { schedules: [...] } + +``` + +#### Create a schedule + +``` + POST /admin/schedules Body: { - busLineId: number, - busId: number, - driverId: number (optional), - startTime: "2026-05-06T08:00:00", - endTime: "2026-05-06T17:00:00" +busLineId: number, +busId: number, +driverId: number (optional), +startTime: "2026-05-06T08:00:00", +endTime: "2026-05-06T17:00:00" } Response: { schedule: {...} } + ``` #### Delete a schedule + ``` + DELETE /admin/schedules/:scheduleId Response: { success: true, schedule: {...} } + ``` ### Public Endpoints (no authentication required) #### Get schedules (with optional filters) + ``` + GET /schedules?date=2026-05-06&lineId=1 Query params: - - date: YYYY-MM-DD (optional) - - lineId: number (optional) -Response: { schedules: [...] } + +- date: YYYY-MM-DD (optional) +- lineId: number (optional) + Response: { schedules: [...] } + ``` #### Get schedule detail + ``` + GET /schedules/:scheduleId Response: { schedule: {...} } + ``` #### Get stops for a schedule's line + ``` + GET /schedules/:scheduleId/stops Response: { stops: [...] } Shows all stops on the bus line with order, location, and ETA. -``` + +```` ## Admin UI Features The ScheduleMaker component provides: -1. **Service Creation Form** - - Select bus line, vehicle, date, and time - - Optional driver ID assignment - - Submit to create schedule - - Admin-only access +1. **Weekly schedule editor** + +- Select a bus line and one of weekdays, Saturday, or Sunday +- Define start and end times +- Assign drivers through shifts +- Admin-only access 2. **Timetable View** - 24-hour time slots (hourly grouping) @@ -86,12 +169,13 @@ The ScheduleMaker component provides: The TimetableViewer component provides a public-facing schedule display: ```jsx -import TimetableViewer from './components/TimetableViewer' +import TimetableViewer from "./components/TimetableViewer"; - -``` +; +```` Features: + - Filter by date and bus line - Real-time loading state - Error handling @@ -104,6 +188,7 @@ Features: With `DEV_MOCK=1`, all schedule operations work in-memory without a database. Example: + ```bash ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start ``` @@ -114,14 +199,19 @@ ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api s CREATE TABLE transit.schedules ( id BIGINT PRIMARY KEY, bus_line_id BIGINT REFERENCES transit.bus_lines(id), - bus_id BIGINT REFERENCES fleet.bus(id), - driver_id INTEGER, - start_time TIMESTAMP, - end_time TIMESTAMP, + start_time TIME, + end_time TIME, status VARCHAR(20) DEFAULT 'planned', + day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); + +CREATE TABLE transit.shifts ( + id BIGINT PRIMARY KEY, + schedule_id BIGINT REFERENCES transit.schedules(id) ON DELETE CASCADE, + driver_id BIGINT REFERENCES workers.users(id) +); ``` ## Styling & UX @@ -129,6 +219,7 @@ CREATE TABLE transit.schedules ( The schedule manager uses: ### Admin Timetable Styling + - Responsive table with hourly time slots - Gradient schedule cards (blue/indigo theme) - Hover effects and smooth transitions @@ -137,6 +228,7 @@ The schedule manager uses: - Confirmation modals with semi-transparent overlay ### Public Timetable Styling + - Clean, simple table layout - Status badges (green for active, gray for planned) - Mobile-responsive with scrolling @@ -152,6 +244,7 @@ The schedule manager uses: ## Example Usage ### Create a schedule (Admin) + ```bash curl -X POST http://localhost:5001/admin/schedules \ -H "Authorization: Bearer YOUR_TOKEN" \ @@ -166,11 +259,13 @@ curl -X POST http://localhost:5001/admin/schedules \ ``` ### Get today's schedules (Public) + ```bash curl http://localhost:5001/schedules?date=$(date +%Y-%m-%d) ``` ### Get schedules for specific line (Public) + ```bash curl http://localhost:5001/schedules?lineId=3 ``` @@ -184,4 +279,3 @@ curl http://localhost:5001/schedules?lineId=3 - Schedule conflict detection - Integration with GPS tracking for actual vs scheduled times - Historical schedule analytics - diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx index 565ed88..b50c7ba 100644 --- a/admin_ui/src/components/ScheduleMaker.jsx +++ b/admin_ui/src/components/ScheduleMaker.jsx @@ -1,93 +1,61 @@ -import React, { useEffect, useState } from 'react' -import { transitClient } from '../api' -import TimetableDisplay from './TimetableDisplay' -import FixedScheduleEditor from './FixedScheduleEditor' +import React, { useEffect, useState } from "react"; +import { transitClient } from "../api"; +import WeeklyScheduleEditor from "./WeeklyScheduleEditor"; -export default function ScheduleMaker({ token, user }) { - const client = transitClient(token) - const [scheduleTemplates, setScheduleTemplates] = useState([]) - const [busLines, setBusLines] = useState([]) - const [buses, setBuses] = useState([]) - const [drivers, setDrivers] = useState([]) - const [selectedDriverFilter, setSelectedDriverFilter] = useState('') - const [message, setMessage] = useState(null) - const [isAdmin] = useState(Boolean(user?.isAdmin)) - const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0]) +export default function ScheduleMaker({ token }) { + const client = transitClient(token); + const [schedules, setSchedules] = useState([]); + const [busLines, setBusLines] = useState([]); + const [drivers, setDrivers] = useState([]); + const [message, setMessage] = useState(null); - const loadScheduleTemplates = async () => { + const loadSchedules = async () => { try { - const res = await client.get('/admin/schedules?templates=1') - setScheduleTemplates((res.data.schedules || []).filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0)) + const res = await client.get("/admin/weekly-schedules"); + setSchedules(res.data.schedules || []); } catch (err) { - setMessage('Error loading fixed schedules: ' + (err.response?.data?.error || err.message)) + setMessage("Could not load weekly schedules."); } - } + }; const loadBusLines = async () => { try { - const res = await client.get('/admin/bus-lines') - setBusLines(res.data.busLines || []) + const res = await client.get("/admin/bus-lines"); + setBusLines(res.data.busLines || []); } catch (err) { - setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message)) + setMessage("Could not load bus lines."); } - } - - const loadBuses = async () => { - try { - const res = await client.get('/admin/fleet/buses') - setBuses(res.data.buses || []) - } catch (err) { - setMessage('Error loading buses: ' + (err.response?.data?.error || err.message)) - } - } + }; const loadDrivers = async () => { try { - const res = await client.get('/admin/drivers') - setDrivers(res.data.drivers || []) + const res = await client.get("/admin/shift-drivers"); + setDrivers(res.data.drivers || []); } catch (err) { - setMessage('Error loading drivers: ' + (err.response?.data?.error || err.message)) + setMessage("Could not load drivers."); } - } + }; useEffect(() => { - loadScheduleTemplates() - loadBusLines() - loadBuses() - loadDrivers() - }, []) + loadSchedules(); + loadBusLines(); + loadDrivers(); + }, []); const handleScheduleChange = async () => { - await loadScheduleTemplates() - } + await loadSchedules(); + }; return (
- {/* Timetable widget - compact daily view */} - - - {/* Fixed service editor - separate widget */} - {isAdmin && ( - - )} + {message &&
{message}
}
- ) + ); } diff --git a/admin_ui/src/components/WeeklyScheduleEditor.jsx b/admin_ui/src/components/WeeklyScheduleEditor.jsx new file mode 100644 index 0000000..d702cfe --- /dev/null +++ b/admin_ui/src/components/WeeklyScheduleEditor.jsx @@ -0,0 +1,268 @@ +import React, { useMemo, useState } from "react"; + +const DAY_GROUPS = [ + { value: "weekdays", label: "Weekdays", days: [1, 2, 3, 4, 5] }, + { value: "saturday", label: "Saturday", days: [6] }, + { value: "sunday", label: "Sunday", days: [0] }, +]; + +const dayLabel = (day) => + [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ][day]; + +export default function WeeklyScheduleEditor({ + client, + busLines, + drivers, + schedules, + onChange, +}) { + const [lineId, setLineId] = useState(""); + const [category, setCategory] = useState("weekdays"); + const [startTimes, setStartTimes] = useState(["08:00"]); + const [newStartTime, setNewStartTime] = useState(""); + const [driverId, setDriverId] = useState(""); + const [message, setMessage] = useState(null); + const [saving, setSaving] = useState(false); + + const groupedSchedules = useMemo(() => { + return schedules.reduce((groups, schedule) => { + const key = + schedule.dayOfWeek >= 1 && schedule.dayOfWeek <= 5 + ? "weekdays" + : schedule.dayOfWeek === 6 + ? "saturday" + : "sunday"; + groups[key] = [...(groups[key] || []), schedule]; + return groups; + }, {}); + }, [schedules]); + + const createSchedules = async (event) => { + event.preventDefault(); + setMessage(null); + if (!lineId || startTimes.length === 0) { + setMessage("Choose a line and add at least one departure time."); + return; + } + + const group = DAY_GROUPS.find((item) => item.value === category); + setSaving(true); + try { + const response = await client.post("/admin/weekly-schedules/bulk", { + busLineId: Number(lineId), + dayOfWeeks: group.days, + startTimes, + driverId: driverId ? Number(driverId) : null, + }); + setMessage( + `${response.data.createdCount} departures created for ${group.label}.`, + ); + setStartTimes(["08:00"]); + setNewStartTime(""); + onChange(); + } catch (error) { + setMessage(error.response?.data?.error || "Could not save the schedule."); + } finally { + setSaving(false); + } + }; + + const addStartTime = () => { + if (!newStartTime || startTimes.includes(newStartTime)) return; + setStartTimes([...startTimes, newStartTime].sort()); + setNewStartTime(""); + }; + + const removeStartTime = (time) => { + setStartTimes(startTimes.filter((item) => item !== time)); + }; + + const assignDriver = async (scheduleId, value) => { + try { + await client.put(`/admin/weekly-schedules/${scheduleId}/shift`, { + driverId: value ? Number(value) : null, + }); + setMessage("Driver assignment updated."); + onChange(); + } catch (error) { + setMessage( + error.response?.data?.error || + "Could not update the driver assignment.", + ); + } + }; + + const removeSchedule = async (scheduleId) => { + if (!window.confirm("Delete this weekly schedule?")) return; + try { + await client.delete(`/admin/weekly-schedules/${scheduleId}`); + setMessage("Weekly schedule deleted."); + onChange(); + } catch (error) { + setMessage( + error.response?.data?.error || "Could not delete the schedule.", + ); + } + }; + + return ( +
+
+
+

Weekly schedules

+

+ Add multiple daily departures for weekdays, Saturday, or Sunday, + then assign a driver to the generated templates. +

+
+
+ +
+
+ + +
+
+ setNewStartTime(event.target.value)} + aria-label="New departure time" + /> + +
+ {startTimes.map((time) => ( + + {time} + + + ))} +
+ + +
+
+ + {message &&
{message}
} + + {DAY_GROUPS.map((group) => ( +
+

{group.label}

+ {(groupedSchedules[group.value] || []).length === 0 ? ( +

No schedules configured.

+ ) : ( +
+ {groupedSchedules[group.value].map((schedule) => ( +
+
+ Line {schedule.lineNumber} + + {String(schedule.startTime).slice(0, 5)} -{" "} + {String(schedule.endTime).slice(0, 5)} + +
+
+ {dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "} + {schedule.endStop} +
+
+ + +
+
+ ))} +
+ )} +
+ ))} +
+ ); +} diff --git a/transit_api/README.md b/transit_api/README.md index 9433513..c9fe502 100644 --- a/transit_api/README.md +++ b/transit_api/README.md @@ -61,9 +61,56 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header. ### Schedule +Schedules are weekly templates. Each row in `transit.schedules` represents one +departure time for one bus line and one `day_of_week`: + +- `1` to `5`: weekdays +- `6`: Saturday +- `0`: Sunday + +Driver assignments are stored separately in `transit.shifts` with +`(schedule_id, driver_id)`, where `driver_id` references `workers.users(id)`. +The current `shifts` shape represents the active assignment. To retain different +assignments for different calendar weeks, add a `week_start` (or assignment date +range) column and include it in the unique key. + +**GET** `/driver/schedules/daily?date=2026-09-18` + +- Returns only weekly schedule templates assigned to the authenticated driver through `transit.shifts`. + +**PATCH** `/driver/weekly-schedules/:scheduleId/status` + +- Updates the status of an assigned weekly schedule. + +**GET** `/admin/weekly-schedules` + +- Lists weekly templates and their current driver assignments. + +**GET** `/admin/shift-drivers` + +- Lists drivers from `workers.users` for shift assignment. + +**POST** `/admin/weekly-schedules` + +- Creates one template. Body: `{ "busLineId": 1, "dayOfWeek": 1, "startTime": "08:00", "endTime": "09:00", "driverId": 12 }`. + +**POST** `/admin/weekly-schedules/bulk` + +- Creates multiple departures in one transaction. The API derives each end time + from the selected bus line duration. Body: `{ "busLineId": 1, "dayOfWeeks": [1,2,3,4,5], "startTimes": ["06:00", "06:30", "07:00"], "driverId": 12 }`. + +**PUT** `/admin/weekly-schedules/:scheduleId/shift` + +- Replaces the driver assignment. Pass `{ "driverId": 12 }` or `{ "driverId": null }`. + +The legacy `/admin/schedules` and `/schedule/daily` endpoints remain for older +clients, but new clients must use the weekly schedule endpoints above. + **GET** `/schedule/daily?date=2024-05-05` + - Ophalen van dagplanning voor chauffeur - Response: + ```json { "date": "2024-05-05", @@ -88,14 +135,16 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header. ``` **GET** `/routes/:routeId` + - Details van een specifieke route - Response: `{ "route": {...} }` ### Public Schedule Overview **GET** `/schedules/overview?date=2026-05-06&lineId=1` + - Haalt een datum-gebonden overzicht op van geplande diensten -- Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays` +- Weekly templates are selected by `day_of_week` and driver assignments by `shifts` - Incidentele diensten worden alleen meegenomen op hun exacte datum - Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status` - Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }` @@ -103,6 +152,7 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header. De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande clients. Voorbeeld: + ```bash curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&busStopId=3" ``` @@ -110,8 +160,10 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu ### Navigation **POST** `/navigation/route` + - Opslaan van berekende route van OSRM - Body: + ```json { "routeId": 1, @@ -129,8 +181,10 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu ``` **GET** `/bus-lines/:busLineId/stops` + - Alle stops van een buslijn (voor navigatie waypoints) - Response: + ```json { "stops": [ @@ -138,8 +192,8 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu "id": 1, "order": 1, "name": "Central Station", - "latitude": 52.5200, - "longitude": 13.4050 + "latitude": 52.52, + "longitude": 13.405 } ] } @@ -148,13 +202,15 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu ### GPS Tracking **POST** `/gps/track` + - Real-time positie opslaan - Body: + ```json { "routeId": 1, - "latitude": 52.5210, - "longitude": 13.4060, + "latitude": 52.521, + "longitude": 13.406, "speed": 45.5, "heading": 180, "accuracy": 5.0 @@ -162,15 +218,17 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu ``` **GET** `/gps/route/:routeId` + - Alle opgeslagen positie punten van een route - Response: + ```json { "positions": [ { "id": 1, - "latitude": 52.5210, - "longitude": 13.4060, + "latitude": 52.521, + "longitude": 13.406, "speed": 45.5, "heading": 180, "recordedAt": "2024-05-05T08:15:00Z" @@ -182,6 +240,7 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu ### Route Status **PATCH** `/routes/:routeId/status` + - Update route status - Body: `{ "status": "in_progress" }` - Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled` @@ -191,19 +250,23 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` voorkomt in `ADMIN_USER_CODES`. **POST** `/admin/stops` + - Maak een herbruikbare stop aan - Body: + ```json { "name": "Central Station", - "latitude": 52.5200, - "longitude": 13.4050 + "latitude": 52.52, + "longitude": 13.405 } ``` **POST** `/admin/bus-lines/:busLineId/stops` + - Koppel een bestaande stop aan een buslijn - Body: + ```json { "stopId": 1, @@ -215,12 +278,14 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v ## Database Schema ### bus_types + - `id` - Primary key - `name` - Bus type name (Standard Bus, etc.) - `seat_capacity` - Number of seats - `license_plate` - Unique license plate ### bus_lines + - `id` - Primary key - `line_number` - Unique line number (1, 5, 12, etc.) - `start_stop` - Start stop name @@ -228,11 +293,13 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v - `estimated_duration_minutes` - Expected route duration ### stops + - `id` - Primary key - `name` - Stop name - `latitude`, `longitude` - Stop location ### route_stops + - `id` - Primary key - `bus_line_id` - Which bus line the stop belongs to - `stop_id` - Reusable stop reference @@ -240,6 +307,7 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v - `estimated_arrival_minutes` - Optional ETA per stop ### routes + - `id` - Primary key - `user_id` - Chauffeur (references workers.users) - `bus_line_id` - Which line (references bus_lines) @@ -248,6 +316,7 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v - `status` - scheduled/in_progress/completed/cancelled ### navigation_routes + - `id` - Primary key - `route_id` - Link to route (unique) - `polyline_geojson` - GeoJSON LineString van de route @@ -255,6 +324,7 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v - `total_duration_seconds` - Estimated duration ### gps_tracking + - `id` - Primary key - `route_id` - Which route - `latitude`, `longitude` - Current position @@ -287,6 +357,7 @@ await scheduleService.updateRouteStatus(1, 'in_progress'); ## Error Handling Alle endpoints retourneren error responses: + ```json { "error": "error_code", @@ -295,6 +366,7 @@ Alle endpoints retourneren error responses: ``` Common errors: + - `missing_token` - No Authorization header - `invalid_token` - Token verification failed - `missing_required_fields` - Required data missing diff --git a/transit_api/index.js b/transit_api/index.js index c1ead49..6dff590 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -1,31 +1,32 @@ -const express = require('express'); -const cors = require('cors'); -require('dotenv').config(); +const express = require("express"); +const cors = require("cors"); +require("dotenv").config(); -const db = require('./db'); -const { authMiddleware } = require('./middleware'); +const db = require("./db"); +const { authMiddleware } = require("./middleware"); const app = express(); app.use(express.json()); app.use(cors()); -const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log'; +const ERROR_LOG_API_URL = + process.env.ERROR_LOG_API_URL || "http://localhost:5003/error_log"; function friendlyErrorMessage(statusCode) { - if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.'; - if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.'; - if (statusCode === 403) return 'Je hebt hier geen toegang toe.'; - if (statusCode === 404) return 'Niet gevonden.'; - return 'Er ging iets mis. Probeer het later opnieuw.'; + if (statusCode === 400) return "Controleer je invoer en probeer opnieuw."; + if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in."; + if (statusCode === 403) return "Je hebt hier geen toegang toe."; + if (statusCode === 404) return "Niet gevonden."; + return "Er ging iets mis. Probeer het later opnieuw."; } async function reportError({ partOfService, error }) { try { await fetch(ERROR_LOG_API_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - service: 'transit_api', + service: "transit_api", partOfService, error, }), @@ -36,9 +37,13 @@ async function reportError({ partOfService, error }) { app.use((req, res, next) => { const originalJson = res.json.bind(res); res.json = (body) => { - if (res.statusCode >= 400 && body && typeof body === 'object') { - const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`; - void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError }); + if (res.statusCode >= 400 && body && typeof body === "object") { + const rawError = + body.error || body.message || body.details || `HTTP ${res.statusCode}`; + void reportError({ + partOfService: `${req.method} ${req.originalUrl}`, + error: rawError, + }); return originalJson({ error: friendlyErrorMessage(res.statusCode) }); } return originalJson(body); @@ -48,7 +53,7 @@ app.use((req, res, next) => { }); // Development mock storage when DEV_MOCK=1 -const DEV_MOCK = process.env.DEV_MOCK === '1'; +const DEV_MOCK = process.env.DEV_MOCK === "1"; const _mock = { stops: [], routeStops: [], @@ -69,40 +74,140 @@ const _mock = { // Initialize mock drivers if (DEV_MOCK) { _mock.drivers = [ - { id: 1, name: 'Jan Peterse', email: 'jan@transit.local', phone: '06-12345678', status: 'active' }, - { id: 2, name: 'Maria Kuijpers', email: 'maria@transit.local', phone: '06-87654321', status: 'active' }, - { id: 3, name: 'Robert Smits', email: 'robert@transit.local', phone: '06-55555555', status: 'active' }, - { id: 4, name: 'Anja Voorn', email: 'anja@transit.local', phone: '06-44444444', status: 'inactive' }, + { + id: 1, + name: "Jan Peterse", + email: "jan@transit.local", + phone: "06-12345678", + status: "active", + }, + { + id: 2, + name: "Maria Kuijpers", + email: "maria@transit.local", + phone: "06-87654321", + status: "active", + }, + { + id: 3, + name: "Robert Smits", + email: "robert@transit.local", + phone: "06-55555555", + status: "active", + }, + { + id: 4, + name: "Anja Voorn", + email: "anja@transit.local", + phone: "06-44444444", + status: "inactive", + }, ]; _mock.nextDriverId = 5; // Initialize test bus lines _mock.busLines = [ - { id: 1, lineNumber: 1, startStop: 'Central Station', endStop: 'Airport', estimatedDurationMinutes: 45, description: 'Main route' }, - { id: 2, lineNumber: 2, startStop: 'Central Station', endStop: 'Harbor', estimatedDurationMinutes: 30, description: 'Harbor route' }, + { + id: 1, + lineNumber: 1, + startStop: "Central Station", + endStop: "Airport", + estimatedDurationMinutes: 45, + description: "Main route", + }, + { + id: 2, + lineNumber: 2, + startStop: "Central Station", + endStop: "Harbor", + estimatedDurationMinutes: 30, + description: "Harbor route", + }, ]; _mock.nextBusLineId = 3; // Initialize test stops _mock.stops = [ - { id: 1, name: 'Central Station', latitude: 52.0116, longitude: 4.7055, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, - { id: 2, name: 'City Center', latitude: 52.0125, longitude: 4.7100, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, - { id: 3, name: 'Airport', latitude: 52.0300, longitude: 4.7500, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + { + id: 1, + name: "Central Station", + latitude: 52.0116, + longitude: 4.7055, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 2, + name: "City Center", + latitude: 52.0125, + longitude: 4.71, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 3, + name: "Airport", + latitude: 52.03, + longitude: 4.75, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, ]; _mock.nextStopId = 4; // Initialize test route stops _mock.routeStops = [ - { id: 1, busLineId: 1, stopId: 1, stopOrder: 1, estimatedArrivalMinutes: 0, name: 'Central Station', latitude: 52.0116, longitude: 4.7055 }, - { id: 2, busLineId: 1, stopId: 2, stopOrder: 2, estimatedArrivalMinutes: 15, name: 'City Center', latitude: 52.0125, longitude: 4.7100 }, - { id: 3, busLineId: 1, stopId: 3, stopOrder: 3, estimatedArrivalMinutes: 45, name: 'Airport', latitude: 52.0300, longitude: 4.7500 }, + { + id: 1, + busLineId: 1, + stopId: 1, + stopOrder: 1, + estimatedArrivalMinutes: 0, + name: "Central Station", + latitude: 52.0116, + longitude: 4.7055, + }, + { + id: 2, + busLineId: 1, + stopId: 2, + stopOrder: 2, + estimatedArrivalMinutes: 15, + name: "City Center", + latitude: 52.0125, + longitude: 4.71, + }, + { + id: 3, + busLineId: 1, + stopId: 3, + stopOrder: 3, + estimatedArrivalMinutes: 45, + name: "Airport", + latitude: 52.03, + longitude: 4.75, + }, ]; _mock.nextRouteStopId = 4; // Initialize test fleet buses _mock.fleetBuses = [ - { id: 1, name: 'Bus 101', seatCapacity: 45, licensePlate: 'AB-01-CD', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, - { id: 2, name: 'Bus 102', seatCapacity: 50, licensePlate: 'AB-02-CD', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + { + id: 1, + name: "Bus 101", + seatCapacity: 45, + licensePlate: "AB-01-CD", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 2, + name: "Bus 102", + seatCapacity: 50, + licensePlate: "AB-02-CD", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, ]; _mock.nextFleetBusId = 3; @@ -121,7 +226,7 @@ if (DEV_MOCK) { startTime: tomorrow.toISOString(), endTime: endTime.toISOString(), weekdays: [], - status: 'planned', + status: "planned", createdAt: new Date().toISOString(), }, ]; @@ -130,7 +235,7 @@ if (DEV_MOCK) { const adminMiddleware = (req, res, next) => { if (!req.user || !req.user.isAdmin) { - return res.status(403).json({ error: 'forbidden' }); + return res.status(403).json({ error: "forbidden" }); } next(); @@ -146,22 +251,24 @@ const adminMiddleware = (req, res, next) => { */ function calculateDepartureTimes(startTime, busLineId, mock) { const startDate = new Date(startTime); - + // Get route stops for this bus line const routeStops = mock.routeStops - .filter(rs => rs.busLineId === busLineId) + .filter((rs) => rs.busLineId === busLineId) .sort((a, b) => a.stopOrder - b.stopOrder); - return routeStops.map(rs => { + return routeStops.map((rs) => { const departureDate = new Date(startDate); // Add estimated arrival minutes to get departure time - departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0)); - - const stop = mock.stops.find(s => s.id === rs.stopId); + departureDate.setMinutes( + departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0), + ); + + const stop = mock.stops.find((s) => s.id === rs.stopId); return { order: rs.stopOrder, stopId: rs.stopId, - stopName: stop?.name || 'Unknown Stop', + stopName: stop?.name || "Unknown Stop", estimatedArrivalMinutes: rs.estimatedArrivalMinutes || 0, departureTime: departureDate.toISOString(), }; @@ -170,34 +277,35 @@ function calculateDepartureTimes(startTime, busLineId, mock) { function parseWeekdaysValue(value) { if (!value) return []; - if (Array.isArray(value)) return value.map(v => Number(v)).filter(v => Number.isFinite(v)); + if (Array.isArray(value)) + return value.map((v) => Number(v)).filter((v) => Number.isFinite(v)); - if (typeof value === 'string') { + if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return []; try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { - return parsed.map(v => Number(v)).filter(v => Number.isFinite(v)); + return parsed.map((v) => Number(v)).filter((v) => Number.isFinite(v)); } } catch (_) { // Fall through to CSV/PG-array parsing. } // Handles values like "1,2,3" or "{1,2,3}". - const normalized = trimmed.replace('{', '').replace('}', ''); + const normalized = trimmed.replace("{", "").replace("}", ""); return normalized - .split(',') - .map(v => Number(v.trim())) - .filter(v => Number.isFinite(v)); + .split(",") + .map((v) => Number(v.trim())) + .filter((v) => Number.isFinite(v)); } return []; } function toNumberOrNull(value) { - if (value === undefined || value === null || value === '') return null; + if (value === undefined || value === null || value === "") return null; const numberValue = Number(value); return Number.isFinite(numberValue) ? numberValue : null; } @@ -205,12 +313,12 @@ function toNumberOrNull(value) { function parseCommaSeparatedValues(value) { if (!value && value !== 0) return []; if (Array.isArray(value)) { - return value.map(item => String(item).trim()).filter(Boolean); + return value.map((item) => String(item).trim()).filter(Boolean); } return String(value) - .split(',') - .map(item => item.trim()) + .split(",") + .map((item) => item.trim()) .filter(Boolean); } @@ -240,7 +348,7 @@ function expandRecurringScheduleRows(rows) { const templateDayOfWeek = templateDate.getDay(); const sortedWeekdays = [...new Set(weekdays)].sort((a, b) => a - b); const durationMinutes = Number( - row.estimated_duration_minutes ?? row.estimatedDurationMinutes ?? 60 + row.estimated_duration_minutes ?? row.estimatedDurationMinutes ?? 60, ); for (let week = 0; week < 52; week++) { @@ -279,7 +387,10 @@ function mapAdminScheduleRow(row, routeStopsByLine = new Map()) { id: Number(row.id), busLineId, busId: Number(row.busId ?? row.bus_id), - driverId: row.driverId !== null && row.driverId !== undefined ? Number(row.driverId) : null, + driverId: + row.driverId !== null && row.driverId !== undefined + ? Number(row.driverId) + : null, driverName: row.driverName ?? row.driver_name ?? null, startTime, endTime: row.endTime ?? row.end_time, @@ -287,7 +398,7 @@ function mapAdminScheduleRow(row, routeStopsByLine = new Map()) { departureTimes, status: row.status, lineNumber: Number(row.lineNumber ?? row.line_number) || null, - busName: row.busName ?? row.bus_name ?? 'Unknown Bus', + busName: row.busName ?? row.bus_name ?? "Unknown Bus", licensePlate: row.licensePlate ?? row.license_plate ?? null, createdAt: row.createdAt ?? row.created_at ?? null, isRecurringInstance: row.is_recurring_instance || false, @@ -297,13 +408,17 @@ function mapAdminScheduleRow(row, routeStopsByLine = new Map()) { }; } -function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) { +function scheduleMatchesFilters( + schedule, + filters, + routeStopsByLine = new Map(), +) { if (!schedule || !filters) return true; const scheduleBusLineId = Number(schedule.bus_line_id ?? schedule.busLineId); const scheduleBusId = Number(schedule.bus_id ?? schedule.busId); const scheduleDriverId = Number(schedule.driver_id ?? schedule.driverId); - const scheduleStatus = String(schedule.status || ''); + const scheduleStatus = String(schedule.status || ""); const scheduleDateKey = toDateKey(schedule.start_time ?? schedule.startTime); if (filters.busLineId !== null && scheduleBusLineId !== filters.busLineId) { @@ -318,7 +433,10 @@ function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) return false; } - if (filters.statuses.length > 0 && !filters.statuses.includes(scheduleStatus)) { + if ( + filters.statuses.length > 0 && + !filters.statuses.includes(scheduleStatus) + ) { return false; } @@ -326,7 +444,11 @@ function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) return false; } - if (filters.fromDate && scheduleDateKey && scheduleDateKey < filters.fromDate) { + if ( + filters.fromDate && + scheduleDateKey && + scheduleDateKey < filters.fromDate + ) { return false; } @@ -336,7 +458,10 @@ function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) if (filters.stopId !== null) { const routeStops = routeStopsByLine.get(scheduleBusLineId) || []; - const matchesStop = routeStops.some(routeStop => Number(routeStop.stop_id ?? routeStop.stopId) === filters.stopId); + const matchesStop = routeStops.some( + (routeStop) => + Number(routeStop.stop_id ?? routeStop.stopId) === filters.stopId, + ); if (!matchesStop) { return false; } @@ -346,50 +471,65 @@ function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) } function parseScheduleFilters(query, options = {}) { - const statuses = parseCommaSeparatedValues(query.status || query.statuses || options.statuses || []); + const statuses = parseCommaSeparatedValues( + query.status || query.statuses || options.statuses || [], + ); return { date: query.date ? String(query.date).trim() : null, fromDate: query.fromDate ? String(query.fromDate).trim() : null, toDate: query.toDate ? String(query.toDate).trim() : null, - driverId: toNumberOrNull(query.driverId ?? query.driver_id ?? query.chauffeurId ?? query.chauffeur_id), + driverId: toNumberOrNull( + query.driverId ?? + query.driver_id ?? + query.chauffeurId ?? + query.chauffeur_id, + ), busId: toNumberOrNull(query.busId ?? query.bus_id), - busLineId: toNumberOrNull(query.busLineId ?? query.lineId ?? query.bus_line_id), - stopId: toNumberOrNull(query.stopId ?? query.stop_id ?? query.busStopId ?? query.bus_stop_id), + busLineId: toNumberOrNull( + query.busLineId ?? query.lineId ?? query.bus_line_id, + ), + stopId: toNumberOrNull( + query.stopId ?? query.stop_id ?? query.busStopId ?? query.bus_stop_id, + ), statuses, }; } function formatWeekdayLabel(weekdays) { - const labels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za']; + const labels = ["Zo", "Ma", "Di", "Wo", "Do", "Vr", "Za"]; if (!Array.isArray(weekdays) || weekdays.length === 0) { - return 'Eenmalig'; + return "Eenmalig"; } if (weekdays.length === 7) { - return 'Dagelijks'; + return "Dagelijks"; } return [...new Set(weekdays)] .sort((a, b) => a - b) - .filter(day => day >= 0 && day < labels.length) - .map(day => labels[day]) - .join(', '); + .filter((day) => day >= 0 && day < labels.length) + .map((day) => labels[day]) + .join(", "); } function buildDepartureTimes(startTime, routeStops = []) { const startDate = new Date(startTime); - return routeStops.map(routeStop => { + return routeStops.map((routeStop) => { const departureDate = new Date(startDate); - const etaMinutes = Number(routeStop.estimated_arrival_minutes ?? routeStop.estimatedArrivalMinutes) || 0; + const etaMinutes = + Number( + routeStop.estimated_arrival_minutes ?? + routeStop.estimatedArrivalMinutes, + ) || 0; departureDate.setMinutes(departureDate.getMinutes() + etaMinutes); return { order: Number(routeStop.stop_order ?? routeStop.stopOrder), stopId: Number(routeStop.stop_id ?? routeStop.stopId), - stopName: routeStop.stop_name || routeStop.name || 'Unknown Stop', + stopName: routeStop.stop_name || routeStop.name || "Unknown Stop", estimatedArrivalMinutes: etaMinutes, departureTime: departureDate.toISOString(), }; @@ -422,7 +562,11 @@ function scheduleOccursOnDate(schedule, dateKey) { return weekdays.includes(targetDate.getDay()); } -function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map()) { +function materializeScheduleOverviewRow( + row, + dateKey, + routeStopsByLine = new Map(), +) { const weekdays = parseWeekdaysValue(row.weekdays); const recurring = weekdays.length > 0; const templateStart = new Date(row.startTime ?? row.start_time); @@ -436,15 +580,20 @@ function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), - templateStart.getMilliseconds() + templateStart.getMilliseconds(), ); startTime = occurrenceStart.toISOString(); - if (Number.isFinite(templateEnd.getTime()) && Number.isFinite(templateStart.getTime())) { + if ( + Number.isFinite(templateEnd.getTime()) && + Number.isFinite(templateStart.getTime()) + ) { const durationMillis = templateEnd.getTime() - templateStart.getTime(); const occurrenceEnd = new Date(occurrenceStart); - occurrenceEnd.setMilliseconds(occurrenceEnd.getMilliseconds() + durationMillis); + occurrenceEnd.setMilliseconds( + occurrenceEnd.getMilliseconds() + durationMillis, + ); endTime = occurrenceEnd.toISOString(); } } @@ -465,10 +614,10 @@ function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map weekdays, status: row.status, lineNumber: Number(row.lineNumber ?? row.line_number) || null, - busName: row.busName ?? row.bus_name ?? 'Unknown Bus', + busName: row.busName ?? row.bus_name ?? "Unknown Bus", licensePlate: row.licensePlate ?? row.license_plate ?? null, createdAt: row.createdAt ?? row.created_at ?? null, - scheduleType: recurring ? 'recurring' : 'one_time', + scheduleType: recurring ? "recurring" : "one_time", recurrenceLabel: formatWeekdayLabel(weekdays), isRecurringInstance: recurring && Boolean(dateKey), templateId: row.templateId ?? row.template_id ?? null, @@ -478,12 +627,19 @@ function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map function mapRecurringOccurrenceRows(rows, dateKey, routeStopsByLine) { return rows - .filter(row => scheduleOccursOnDate(row, dateKey)) - .map(row => materializeScheduleOverviewRow(row, dateKey, routeStopsByLine)) + .filter((row) => scheduleOccursOnDate(row, dateKey)) + .map((row) => + materializeScheduleOverviewRow(row, dateKey, routeStopsByLine), + ) .sort((a, b) => new Date(a.startTime) - new Date(b.startTime)); } -function materializeScheduleRowsInRange(rows, fromDateKey, toDateKey, routeStopsByLine) { +function materializeScheduleRowsInRange( + rows, + fromDateKey, + toDateKey, + routeStopsByLine, +) { const occurrences = []; if (!fromDateKey || !toDateKey) return occurrences; @@ -501,7 +657,9 @@ function materializeScheduleRowsInRange(rows, fromDateKey, toDateKey, routeStops if (!weekdays || weekdays.length === 0) { const rowDateKey = toDateKey(row.start_time ?? row.startTime); if (rowDateKey && rowDateKey >= fromDateKey && rowDateKey <= toDateKey) { - occurrences.push(materializeScheduleOverviewRow(row, rowDateKey, routeStopsByLine)); + occurrences.push( + materializeScheduleOverviewRow(row, rowDateKey, routeStopsByLine), + ); } continue; } @@ -518,103 +676,152 @@ function materializeScheduleRowsInRange(rows, fromDateKey, toDateKey, routeStops // ensure cursor >= templateStart (so recurrence doesn't start before template) if (cursor < templateStart) { // advance by weeks until >= templateStart - const diffDays = Math.ceil((templateStart - cursor) / (24 * 60 * 60 * 1000)); + const diffDays = Math.ceil( + (templateStart - cursor) / (24 * 60 * 60 * 1000), + ); const weeksToAdvance = Math.ceil(diffDays / 7); cursor.setDate(cursor.getDate() + weeksToAdvance * 7); } while (cursor <= toDate) { const occurrenceKey = cursor.toISOString().slice(0, 10); - occurrences.push(materializeScheduleOverviewRow(row, occurrenceKey, routeStopsByLine)); + occurrences.push( + materializeScheduleOverviewRow(row, occurrenceKey, routeStopsByLine), + ); cursor.setDate(cursor.getDate() + 7); } } } // sort and return - return occurrences.sort((a, b) => new Date(a.startTime) - new Date(b.startTime)); + return occurrences.sort( + (a, b) => new Date(a.startTime) - new Date(b.startTime), + ); } /** * Check if two schedules have date/weekday overlap */ -function doSchedulesDateOverlap(schedule1Start, schedule1End, schedule1Weekdays, schedule2Start, schedule2End, schedule2Weekdays) { +function doSchedulesDateOverlap( + schedule1Start, + schedule1End, + schedule1Weekdays, + schedule2Start, + schedule2End, + schedule2Weekdays, +) { const s1Start = new Date(schedule1Start); const s1End = new Date(schedule1End); const s2Start = new Date(schedule2Start); const s2End = new Date(schedule2End); - + // Check time overlap if (s1End <= s2Start || s2End <= s1Start) { return false; // No time overlap } - + // If both have no weekdays (one-time), check if same day - if ((!schedule1Weekdays || schedule1Weekdays.length === 0) && - (!schedule2Weekdays || schedule2Weekdays.length === 0)) { + if ( + (!schedule1Weekdays || schedule1Weekdays.length === 0) && + (!schedule2Weekdays || schedule2Weekdays.length === 0) + ) { return s1Start.toDateString() === s2Start.toDateString(); } - + // If one has weekdays and other doesn't, check if one-time falls on weekday - if (schedule1Weekdays && schedule1Weekdays.length > 0 && - (!schedule2Weekdays || schedule2Weekdays.length === 0)) { + if ( + schedule1Weekdays && + schedule1Weekdays.length > 0 && + (!schedule2Weekdays || schedule2Weekdays.length === 0) + ) { const s2Day = s2Start.getDay(); // 0=Sun, 1=Mon, etc. const s2Adjusted = s2Day === 0 ? 0 : s2Day; // Normalize to 0-6 return schedule1Weekdays.includes(s2Adjusted); } - - if ((!schedule1Weekdays || schedule1Weekdays.length === 0) && - schedule2Weekdays && schedule2Weekdays.length > 0) { + + if ( + (!schedule1Weekdays || schedule1Weekdays.length === 0) && + schedule2Weekdays && + schedule2Weekdays.length > 0 + ) { const s1Day = s1Start.getDay(); const s1Adjusted = s1Day === 0 ? 0 : s1Day; return schedule2Weekdays.includes(s1Adjusted); } - + // Both have weekdays: check if any day overlaps if (schedule1Weekdays && schedule2Weekdays) { - return schedule1Weekdays.some(d => schedule2Weekdays.includes(d)); + return schedule1Weekdays.some((d) => schedule2Weekdays.includes(d)); } - + return false; } /** * Check if a driver has conflicts with existing schedules */ -function checkDriverConflict(driverId, startTime, endTime, weekdays, allSchedules, excludeScheduleId = null) { +function checkDriverConflict( + driverId, + startTime, + endTime, + weekdays, + allSchedules, + excludeScheduleId = null, +) { if (!driverId) return false; // No conflict if no driver specified - - return allSchedules.some(s => { + + return allSchedules.some((s) => { if (excludeScheduleId && s.id === excludeScheduleId) return false; const sDriverId = s.driverId ?? s.driver_id; if (Number(sDriverId) !== Number(driverId)) return false; - + const sWeekdays = parseWeekdaysValue(s.weekdays); const wkdays = weekdays || []; const sStart = s.startTime ?? s.start_time; const sEnd = s.endTime ?? s.end_time; - - return doSchedulesDateOverlap(startTime, endTime, wkdays, sStart, sEnd, sWeekdays); + + return doSchedulesDateOverlap( + startTime, + endTime, + wkdays, + sStart, + sEnd, + sWeekdays, + ); }); } /** * Check if a bus has conflicts with existing schedules */ -function checkBusConflict(busId, startTime, endTime, weekdays, allSchedules, excludeScheduleId = null) { +function checkBusConflict( + busId, + startTime, + endTime, + weekdays, + allSchedules, + excludeScheduleId = null, +) { if (!busId) return false; - - return allSchedules.some(s => { + + return allSchedules.some((s) => { if (excludeScheduleId && s.id === excludeScheduleId) return false; const sBusId = s.busId ?? s.bus_id; if (Number(sBusId) !== Number(busId)) return false; - + const sWeekdays = parseWeekdaysValue(s.weekdays); const wkdays = weekdays || []; const sStart = s.startTime ?? s.start_time; const sEnd = s.endTime ?? s.end_time; - - return doSchedulesDateOverlap(startTime, endTime, wkdays, sStart, sEnd, sWeekdays); + + return doSchedulesDateOverlap( + startTime, + endTime, + wkdays, + sStart, + sEnd, + sWeekdays, + ); }); } @@ -695,16 +902,17 @@ async function ensureScheduleStopProgressTable() { // ============================================ // ROUTES - Get daily schedule // ============================================ -app.get('/schedule/daily', authMiddleware, async (req, res) => { +app.get("/schedule/daily", authMiddleware, async (req, res) => { try { const { date } = req.query; const userId = req.user.userId; if (!date) { - return res.status(400).json({ error: 'date query parameter required' }); + return res.status(400).json({ error: "date query parameter required" }); } - const result = await db.query(` + const result = await db.query( + ` SELECT r.id, r.start_time, @@ -725,9 +933,11 @@ app.get('/schedule/daily', authMiddleware, async (req, res) => { JOIN fleet.bus bt ON r.bus_type_id = bt.id WHERE r.user_id = $1 AND DATE(r.start_time) = $2 ORDER BY r.start_time ASC - `, [userId, date]); + `, + [userId, date], + ); - let routes = result.rows.map(row => ({ + let routes = result.rows.map((row) => ({ id: row.id, startTime: row.start_time, endTime: row.end_time, @@ -748,12 +958,13 @@ app.get('/schedule/daily', authMiddleware, async (req, res) => { }, })); - // If no explicit "routes" rows exist for this user/date, fall back to - // checking scheduled runs in `transit.schedules` (filtered by driver_id). - // This works around cases where DB permissions prevent inserting into - // `transit.routes` but schedules exist and should be shown to the driver. - if (!DEV_MOCK && routes.length === 0) { - const fallback = await db.query(` + // If no explicit "routes" rows exist for this user/date, fall back to + // checking scheduled runs in `transit.schedules` (filtered by driver_id). + // This works around cases where DB permissions prevent inserting into + // `transit.routes` but schedules exist and should be shown to the driver. + if (!DEV_MOCK && routes.length === 0) { + const fallback = await db.query( + ` SELECT s.id, s.start_time, @@ -775,94 +986,152 @@ app.get('/schedule/daily', authMiddleware, async (req, res) => { JOIN fleet.bus bt ON s.bus_id = bt.id WHERE s.driver_id = $1 AND DATE(s.start_time) = $2 ORDER BY s.start_time ASC - `, [userId, date]); + `, + [userId, date], + ); - routes = fallback.rows.map(row => ({ - id: row.id, - startTime: row.start_time, - endTime: row.end_time, - status: row.status, - busLine: { - id: row.bus_line_id, - lineNumber: row.line_number, - startStop: row.start_stop, - endStop: row.end_stop, - estimatedDuration: row.estimated_duration_minutes, - description: row.description, - }, - busType: { - id: row.bus_type_id, - name: row.bus_type, - seatCapacity: row.seat_capacity, - licensePlate: row.license_plate, - }, - })); - } + routes = fallback.rows.map((row) => ({ + id: row.id, + startTime: row.start_time, + endTime: row.end_time, + status: row.status, + busLine: { + id: row.bus_line_id, + lineNumber: row.line_number, + startStop: row.start_stop, + endStop: row.end_stop, + estimatedDuration: row.estimated_duration_minutes, + description: row.description, + }, + busType: { + id: row.bus_type_id, + name: row.bus_type, + seatCapacity: row.seat_capacity, + licensePlate: row.license_plate, + }, + })); + } - // If still empty, try to synthesize a single route by shifting a recent - // schedule's time-of-day to the requested date. This is a non-destructive - // fallback to make sure drivers see a trip when DB inserts are restricted. - if (!DEV_MOCK && routes.length === 0) { - const tmplRes = await db.query(` + // If still empty, try to synthesize a single route by shifting a recent + // schedule's time-of-day to the requested date. This is a non-destructive + // fallback to make sure drivers see a trip when DB inserts are restricted. + if (!DEV_MOCK && routes.length === 0) { + const tmplRes = await db.query( + ` SELECT s.start_time, s.end_time, s.status, s.bus_line_id, s.bus_id, s.driver_id, bl.estimated_duration_minutes, bl.line_number, bl.start_stop, bl.end_stop, bl.description FROM transit.schedules s JOIN transit.bus_lines bl ON s.bus_line_id = bl.id WHERE s.driver_id = $1 ORDER BY s.start_time DESC LIMIT 1 - `, [userId]); + `, + [userId], + ); - if (tmplRes.rowCount > 0) { - const t = tmplRes.rows[0]; - const requestedDate = new Date(date + 'T00:00:00.000Z'); - const srcStart = new Date(t.start_time); - const srcEnd = new Date(t.end_time); + if (tmplRes.rowCount > 0) { + const t = tmplRes.rows[0]; + const requestedDate = new Date(date + "T00:00:00.000Z"); + const srcStart = new Date(t.start_time); + const srcEnd = new Date(t.end_time); - // Preserve time-of-day from template, apply to requested date - const newStart = new Date(requestedDate); - newStart.setUTCHours(srcStart.getUTCHours(), srcStart.getUTCMinutes(), srcStart.getUTCSeconds(), srcStart.getUTCMilliseconds()); - const durationMs = srcEnd.getTime() - srcStart.getTime(); - const newEnd = new Date(newStart.getTime() + durationMs); + // Preserve time-of-day from template, apply to requested date + const newStart = new Date(requestedDate); + newStart.setUTCHours( + srcStart.getUTCHours(), + srcStart.getUTCMinutes(), + srcStart.getUTCSeconds(), + srcStart.getUTCMilliseconds(), + ); + const durationMs = srcEnd.getTime() - srcStart.getTime(); + const newEnd = new Date(newStart.getTime() + durationMs); - routes.push({ - id: null, - startTime: newStart.toISOString(), - endTime: newEnd.toISOString(), - status: t.status || 'planned', - busLine: { - id: t.bus_line_id, - lineNumber: t.line_number, - startStop: t.start_stop, - endStop: t.end_stop, - estimatedDuration: t.estimated_duration_minutes, - description: t.description, - }, - busType: { - id: t.bus_id, - name: null, - seatCapacity: null, - licensePlate: null, - }, - }); - } + routes.push({ + id: null, + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + status: t.status || "planned", + busLine: { + id: t.bus_line_id, + lineNumber: t.line_number, + startStop: t.start_stop, + endStop: t.end_stop, + estimatedDuration: t.estimated_duration_minutes, + description: t.description, + }, + busType: { + id: t.bus_id, + name: null, + seatCapacity: null, + licensePlate: null, + }, + }); } + } res.json({ date, routes }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); + } +}); + +// DRIVER - Weekly schedule templates assigned through transit.shifts. +// day_of_week uses JavaScript/PostgreSQL convention: 0 Sunday, 1 Monday ... 6 Saturday. +app.get("/driver/schedules/daily", authMiddleware, async (req, res) => { + try { + const { date } = req.query; + if (!date) + return res.status(400).json({ error: "date query parameter required" }); + + const result = await db.query( + ` + SELECT s.id, s.bus_line_id, s.start_time, s.end_time, s.status, s.day_of_week, + bl.line_number, bl.start_stop, bl.end_stop, bl.estimated_duration_minutes, + bl.description + FROM transit.schedules s + JOIN transit.shifts sh ON sh.schedule_id = s.id AND sh.driver_id = $1 + JOIN transit.bus_lines bl ON bl.id = s.bus_line_id + WHERE s.day_of_week = EXTRACT(DOW FROM $2::date)::int + AND s.status <> 'cancelled' + ORDER BY s.start_time ASC + `, + [req.user.userId, date], + ); + + res.json({ + date, + routes: result.rows.map((row) => ({ + id: Number(row.id), + startTime: row.start_time, + endTime: row.end_time, + status: row.status, + dayOfWeek: Number(row.day_of_week), + busLine: { + id: Number(row.bus_line_id), + lineNumber: row.line_number, + startStop: row.start_stop, + endStop: row.end_stop, + estimatedDuration: row.estimated_duration_minutes, + description: row.description, + }, + })), + }); + } catch (err) { + console.error("GET /driver/schedules/daily error:", err); + res.status(500).json({ error: "internal_error" }); } }); // ============================================ // ROUTES - Get single route details // ============================================ -app.get('/routes/:routeId', authMiddleware, async (req, res) => { +app.get("/routes/:routeId", authMiddleware, async (req, res) => { try { const { routeId } = req.params; const userId = req.user.userId; - const result = await db.query(` + const result = await db.query( + ` SELECT r.id, r.start_time, @@ -882,10 +1151,12 @@ app.get('/routes/:routeId', authMiddleware, async (req, res) => { JOIN bus_lines bl ON r.bus_line_id = bl.id JOIN bus_types bt ON r.bus_type_id = bt.id WHERE r.id = $1 AND r.user_id = $2 - `, [routeId, userId]); + `, + [routeId, userId], + ); if (result.rows.length === 0) { - return res.status(404).json({ error: 'route_not_found' }); + return res.status(404).json({ error: "route_not_found" }); } const row = result.rows[0]; @@ -913,21 +1184,21 @@ app.get('/routes/:routeId', authMiddleware, async (req, res) => { res.json({ route }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // ROUTES - Get stops for a bus line // ============================================ -app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => { +app.get("/bus-lines/:busLineId/stops", authMiddleware, async (req, res) => { try { const { busLineId } = req.params; if (DEV_MOCK) { const stops = _mock.routeStops - .filter(rs => String(rs.busLineId) === String(busLineId)) - .sort((a,b) => a.stopOrder - b.stopOrder) - .map(rs => ({ + .filter((rs) => String(rs.busLineId) === String(busLineId)) + .sort((a, b) => a.stopOrder - b.stopOrder) + .map((rs) => ({ id: rs.id, order: rs.stopOrder, name: rs.name, @@ -938,7 +1209,8 @@ app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => { return res.json({ stops }); } - const result = await db.query(` + const result = await db.query( + ` SELECT rs.id, rs.stop_order, @@ -950,9 +1222,11 @@ app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => { JOIN transit.stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = $1 ORDER BY rs.stop_order ASC - `, [busLineId]); + `, + [busLineId], + ); - const stops = result.rows.map(row => ({ + const stops = result.rows.map((row) => ({ id: row.id, order: row.stop_order, name: row.stop_name, @@ -964,17 +1238,19 @@ app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => { res.json({ stops }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // ADMIN - Create reusable stop // ============================================ -app.get('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { +app.get("/admin/stops", authMiddleware, adminMiddleware, async (req, res) => { try { if (DEV_MOCK) { - return res.json({ stops: _mock.stops.slice().sort((a, b) => a.id - b.id) }); + return res.json({ + stops: _mock.stops.slice().sort((a, b) => a.id - b.id), + }); } const result = await db.query(` @@ -984,7 +1260,7 @@ app.get('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { `); res.json({ - stops: result.rows.map(row => ({ + stops: result.rows.map((row) => ({ id: row.id, name: row.name, latitude: parseFloat(row.latitude), @@ -995,16 +1271,16 @@ app.get('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); -app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { +app.post("/admin/stops", authMiddleware, adminMiddleware, async (req, res) => { try { const { name, latitude, longitude } = req.body; if (!name || latitude === undefined || longitude === undefined) { - return res.status(400).json({ error: 'missing_required_fields' }); + return res.status(400).json({ error: "missing_required_fields" }); } if (DEV_MOCK) { @@ -1020,7 +1296,8 @@ app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { return res.status(201).json({ stop }); } - const result = await db.query(` + const result = await db.query( + ` WITH next_id AS ( SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.stops ) @@ -1028,7 +1305,9 @@ app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { SELECT next_id.id, $1, $2, $3 FROM next_id RETURNING id, name, latitude, longitude, created_at, updated_at - `, [name, latitude, longitude]); + `, + [name, latitude, longitude], + ); const stop = result.rows[0]; res.status(201).json({ @@ -1043,65 +1322,90 @@ app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // ADMIN - Manage bus lines // ============================================ -app.get('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) => { - try { - if (DEV_MOCK) { - return res.json({ busLines: _mock.busLines.slice().sort((a, b) => a.lineNumber - b.lineNumber) }); - } +app.get( + "/admin/bus-lines", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + if (DEV_MOCK) { + return res.json({ + busLines: _mock.busLines + .slice() + .sort((a, b) => a.lineNumber - b.lineNumber), + }); + } - const result = await db.query(` + const result = await db.query(` SELECT id, line_number, start_stop, end_stop, estimated_duration_minutes, description FROM transit.bus_lines ORDER BY line_number ASC, id ASC `); - res.json({ - 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); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); - -app.post('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { lineNumber, startStop, endStop, estimatedDurationMinutes, description } = req.body; - - if (lineNumber === undefined || !startStop || !endStop || estimatedDurationMinutes === undefined) { - return res.status(400).json({ error: 'missing_required_fields' }); + res.json({ + 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); + res.status(500).json({ error: "internal_error", details: err.message }); } + }, +); - if (DEV_MOCK) { - const busLine = { - id: _mock.nextBusLineId++, - lineNumber: Number(lineNumber), +app.post( + "/admin/bus-lines", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { + lineNumber, startStop, endStop, - estimatedDurationMinutes: Number(estimatedDurationMinutes), - description: description || '', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - _mock.busLines.push(busLine); - return res.status(201).json({ busLine }); - } + estimatedDurationMinutes, + description, + } = req.body; - const result = await db.query(` + if ( + lineNumber === undefined || + !startStop || + !endStop || + estimatedDurationMinutes === undefined + ) { + return res.status(400).json({ error: "missing_required_fields" }); + } + + if (DEV_MOCK) { + const busLine = { + id: _mock.nextBusLineId++, + lineNumber: Number(lineNumber), + startStop, + endStop, + estimatedDurationMinutes: Number(estimatedDurationMinutes), + description: description || "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + _mock.busLines.push(busLine); + return res.status(201).json({ busLine }); + } + + const result = await db.query( + ` WITH next_id AS ( SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.bus_lines ) @@ -1109,43 +1413,67 @@ app.post('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) = 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]); + `, + [ + lineNumber, + startStop, + endStop, + estimatedDurationMinutes, + description || null, + ], + ); - const busLine = result.rows[0]; - res.status(201).json({ - 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); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); - -app.patch('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { busLineId } = req.params; - const { lineNumber, startStop, endStop, estimatedDurationMinutes, description } = req.body; - - if (DEV_MOCK) { - const existing = _mock.busLines.find(line => String(line.id) === String(busLineId)); - if (!existing) return res.status(404).json({ error: 'bus_line_not_found' }); - if (lineNumber !== undefined) existing.lineNumber = Number(lineNumber); - if (startStop !== undefined) existing.startStop = startStop; - if (endStop !== undefined) existing.endStop = endStop; - if (estimatedDurationMinutes !== undefined) existing.estimatedDurationMinutes = Number(estimatedDurationMinutes); - if (description !== undefined) existing.description = description; - existing.updatedAt = new Date().toISOString(); - return res.json({ busLine: existing }); + const busLine = result.rows[0]; + res.status(201).json({ + 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); + res.status(500).json({ error: "internal_error", details: err.message }); } + }, +); - const result = await db.query(` +app.patch( + "/admin/bus-lines/:busLineId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { busLineId } = req.params; + const { + lineNumber, + startStop, + endStop, + estimatedDurationMinutes, + description, + } = req.body; + + if (DEV_MOCK) { + const existing = _mock.busLines.find( + (line) => String(line.id) === String(busLineId), + ); + if (!existing) + return res.status(404).json({ error: "bus_line_not_found" }); + if (lineNumber !== undefined) existing.lineNumber = Number(lineNumber); + if (startStop !== undefined) existing.startStop = startStop; + if (endStop !== undefined) existing.endStop = endStop; + if (estimatedDurationMinutes !== undefined) + existing.estimatedDurationMinutes = Number(estimatedDurationMinutes); + if (description !== undefined) existing.description = description; + existing.updatedAt = new Date().toISOString(); + return res.json({ busLine: existing }); + } + + const result = await db.query( + ` UPDATE transit.bus_lines SET line_number = COALESCE($1, line_number), @@ -1155,100 +1483,135 @@ app.patch('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async 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]); + `, + [ + lineNumber ?? null, + startStop ?? null, + endStop ?? null, + estimatedDurationMinutes ?? null, + description ?? null, + busLineId, + ], + ); - if (!result.rows[0]) return res.status(404).json({ error: 'bus_line_not_found' }); + if (!result.rows[0]) + return res.status(404).json({ error: "bus_line_not_found" }); - const busLine = result.rows[0]; - res.json({ - 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); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + const busLine = result.rows[0]; + res.json({ + 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); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); // ============================================ // ADMIN - Manage fleet buses // ============================================ -app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => { - try { - if (DEV_MOCK) { - const buses = _mock.fleetBuses.slice().sort((a, b) => a.id - b.id); - return res.json({ - buses: buses.map(b => ({ - ...b, - available: !checkBusConflict(b.id, new Date().toISOString(), new Date(Date.now() + 24*60*60*1000).toISOString(), [], _mock.schedules || []) - })) - }); - } +app.get( + "/admin/fleet/buses", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + if (DEV_MOCK) { + const buses = _mock.fleetBuses.slice().sort((a, b) => a.id - b.id); + return res.json({ + buses: buses.map((b) => ({ + ...b, + available: !checkBusConflict( + b.id, + new Date().toISOString(), + new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + [], + _mock.schedules || [], + ), + })), + }); + } - const result = await db.query(` + const result = await db.query(` SELECT id, name, seat_capacity, license_plate FROM fleet.bus ORDER BY id ASC `); - // Get all schedules to check for conflicts - const schedulesResult = await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`); - const schedules = schedulesResult.rows.map(row => ({ - ...row, - weekdays: parseWeekdaysValue(row.weekdays), - })); + // Get all schedules to check for conflicts + const schedulesResult = await db.query( + `SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`, + ); + const schedules = schedulesResult.rows.map((row) => ({ + ...row, + weekdays: parseWeekdaysValue(row.weekdays), + })); - res.json({ - buses: result.rows.map(row => { - const busId = row.id; - const now = new Date(); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - const isAvailable = !checkBusConflict(busId, now.toISOString(), tomorrow.toISOString(), [], schedules); - - return { - id: row.id, - name: row.name, - seatCapacity: row.seat_capacity, - licensePlate: row.license_plate, - available: isAvailable, + res.json({ + buses: result.rows.map((row) => { + const busId = row.id; + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const isAvailable = !checkBusConflict( + busId, + now.toISOString(), + tomorrow.toISOString(), + [], + schedules, + ); + + return { + id: row.id, + name: row.name, + seatCapacity: row.seat_capacity, + licensePlate: row.license_plate, + available: isAvailable, + }; + }), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); + +app.post( + "/admin/fleet/buses", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { name, seatCapacity, licensePlate } = req.body; + + if (!name || seatCapacity === undefined || !licensePlate) { + return res.status(400).json({ error: "missing_required_fields" }); + } + + if (DEV_MOCK) { + const bus = { + id: _mock.nextFleetBusId++, + name, + seatCapacity: Number(seatCapacity), + licensePlate, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; - }), - }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + _mock.fleetBuses.push(bus); + return res.status(201).json({ bus }); + } -app.post('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { name, seatCapacity, licensePlate } = req.body; - - if (!name || seatCapacity === undefined || !licensePlate) { - return res.status(400).json({ error: 'missing_required_fields' }); - } - - if (DEV_MOCK) { - const bus = { - id: _mock.nextFleetBusId++, - name, - seatCapacity: Number(seatCapacity), - licensePlate, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - _mock.fleetBuses.push(bus); - return res.status(201).json({ bus }); - } - - const result = await db.query(` + const result = await db.query( + ` WITH next_id AS ( SELECT COALESCE(MAX(id), 0) + 1 AS id FROM fleet.bus ) @@ -1256,116 +1619,153 @@ app.post('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) SELECT next_id.id, $1, $2, $3 FROM next_id RETURNING id, name, seat_capacity, license_plate - `, [name, seatCapacity, licensePlate]); + `, + [name, seatCapacity, licensePlate], + ); - const bus = result.rows[0]; - res.status(201).json({ - bus: { - id: bus.id, - name: bus.name, - seatCapacity: bus.seat_capacity, - licensePlate: bus.license_plate, - }, - }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + const bus = result.rows[0]; + res.status(201).json({ + bus: { + id: bus.id, + name: bus.name, + seatCapacity: bus.seat_capacity, + licensePlate: bus.license_plate, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); // ============================================ // ADMIN - Update fleet bus // ============================================ -app.patch('/admin/fleet/buses/:busId', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { busId } = req.params; - const { name, seatCapacity, licensePlate } = req.body; +app.patch( + "/admin/fleet/buses/:busId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { busId } = req.params; + const { name, seatCapacity, licensePlate } = req.body; - if (!busId || !Number.isFinite(Number(busId))) { - return res.status(400).json({ error: 'invalid_bus_id' }); - } + if (!busId || !Number.isFinite(Number(busId))) { + return res.status(400).json({ error: "invalid_bus_id" }); + } - if (DEV_MOCK) { - const bus = _mock.fleetBuses.find(b => Number(b.id) === Number(busId)); - if (!bus) return res.status(404).json({ error: 'bus_not_found' }); - if (name !== undefined) bus.name = name; - if (seatCapacity !== undefined) bus.seatCapacity = Number(seatCapacity); - if (licensePlate !== undefined) bus.licensePlate = licensePlate; - bus.updatedAt = new Date().toISOString(); - return res.json({ bus }); - } + if (DEV_MOCK) { + const bus = _mock.fleetBuses.find( + (b) => Number(b.id) === Number(busId), + ); + if (!bus) return res.status(404).json({ error: "bus_not_found" }); + if (name !== undefined) bus.name = name; + if (seatCapacity !== undefined) bus.seatCapacity = Number(seatCapacity); + if (licensePlate !== undefined) bus.licensePlate = licensePlate; + bus.updatedAt = new Date().toISOString(); + return res.json({ bus }); + } - const result = await db.query(` + const result = await db.query( + ` UPDATE fleet.bus SET name = COALESCE($1, name), seat_capacity = COALESCE($2, seat_capacity), license_plate = COALESCE($3, license_plate), updated_at = NOW() WHERE id = $4 RETURNING id, name, seat_capacity, license_plate - `, [name ?? null, seatCapacity ?? null, licensePlate ?? null, busId]); + `, + [name ?? null, seatCapacity ?? null, licensePlate ?? null, busId], + ); - if (!result.rows[0]) return res.status(404).json({ error: 'bus_not_found' }); + if (!result.rows[0]) + return res.status(404).json({ error: "bus_not_found" }); - const bus = result.rows[0]; - res.json({ bus: { id: bus.id, name: bus.name, seatCapacity: bus.seat_capacity, licensePlate: bus.license_plate } }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + const bus = result.rows[0]; + res.json({ + bus: { + id: bus.id, + name: bus.name, + seatCapacity: bus.seat_capacity, + licensePlate: bus.license_plate, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); // ============================================ // ADMIN - Delete fleet bus // ============================================ -app.delete('/admin/fleet/buses/:busId', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { busId } = req.params; - if (!busId || !Number.isFinite(Number(busId))) { - return res.status(400).json({ error: 'invalid_bus_id' }); - } +app.delete( + "/admin/fleet/buses/:busId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { busId } = req.params; + if (!busId || !Number.isFinite(Number(busId))) { + return res.status(400).json({ error: "invalid_bus_id" }); + } - if (DEV_MOCK) { - const idx = _mock.fleetBuses.findIndex(b => Number(b.id) === Number(busId)); - if (idx === -1) return res.status(404).json({ error: 'bus_not_found' }); - const deleted = _mock.fleetBuses.splice(idx, 1)[0]; - return res.json({ success: true, bus: deleted }); - } + if (DEV_MOCK) { + const idx = _mock.fleetBuses.findIndex( + (b) => Number(b.id) === Number(busId), + ); + if (idx === -1) return res.status(404).json({ error: "bus_not_found" }); + const deleted = _mock.fleetBuses.splice(idx, 1)[0]; + return res.json({ success: true, bus: deleted }); + } - const result = await db.query(`DELETE FROM fleet.bus WHERE id = $1 RETURNING id, name`, [busId]); - if (result.rows.length === 0) return res.status(404).json({ error: 'bus_not_found' }); - res.json({ success: true, bus: result.rows[0] }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + const result = await db.query( + `DELETE FROM fleet.bus WHERE id = $1 RETURNING id, name`, + [busId], + ); + if (result.rows.length === 0) + return res.status(404).json({ error: "bus_not_found" }); + res.json({ success: true, bus: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); // ============================================ // ADMIN - Link stop to bus line // ============================================ -app.get('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { busLineId } = req.params; - if (!busLineId || !Number.isFinite(Number(busLineId))) { - return res.status(400).json({ error: 'invalid_bus_line_id' }); - } +app.get( + "/admin/bus-lines/:busLineId/stops", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { busLineId } = req.params; + if (!busLineId || !Number.isFinite(Number(busLineId))) { + return res.status(400).json({ error: "invalid_bus_line_id" }); + } - if (DEV_MOCK) { - const stops = _mock.routeStops - .filter(rs => String(rs.busLineId) === String(busLineId)) - .sort((a, b) => a.stopOrder - b.stopOrder) - .map(rs => ({ - id: rs.id, - busLineId: rs.busLineId, - stopId: rs.stopId, - stopOrder: rs.stopOrder, - estimatedArrivalMinutes: rs.estimatedArrivalMinutes, - name: rs.name, - latitude: rs.latitude, - longitude: rs.longitude, - })); - return res.json({ routeStops: stops }); - } + if (DEV_MOCK) { + const stops = _mock.routeStops + .filter((rs) => String(rs.busLineId) === String(busLineId)) + .sort((a, b) => a.stopOrder - b.stopOrder) + .map((rs) => ({ + id: rs.id, + busLineId: rs.busLineId, + stopId: rs.stopId, + stopOrder: rs.stopOrder, + estimatedArrivalMinutes: rs.estimatedArrivalMinutes, + name: rs.name, + latitude: rs.latitude, + longitude: rs.longitude, + })); + return res.json({ routeStops: stops }); + } - const result = await db.query(` + const result = await db.query( + ` SELECT rs.id, rs.bus_line_id, @@ -1379,54 +1779,70 @@ app.get('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, as JOIN transit.stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = $1 ORDER BY rs.stop_order ASC - `, [busLineId]); + `, + [busLineId], + ); - res.json({ 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); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); - -app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { busLineId } = req.params; - const { stopId, stopOrder, estimatedArrivalMinutes } = req.body; - - if (!stopId || stopOrder === undefined) { - return res.status(400).json({ error: 'missing_required_fields' }); + res.json({ + 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); + res.status(500).json({ error: "internal_error", details: err.message }); } + }, +); - if (DEV_MOCK) { - const stop = _mock.stops.find(s => String(s.id) === String(stopId)); - const routeStop = { - id: _mock.nextRouteStopId++, - busLineId: Number(busLineId), - stopId: Number(stopId), - stopOrder: Number(stopOrder), - estimatedArrivalMinutes: estimatedArrivalMinutes ?? null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - name: stop ? stop.name : `stop-${stopId}`, - latitude: stop ? stop.latitude : 0, - longitude: stop ? stop.longitude : 0, - }; - // remove existing with same busLineId & stopOrder - _mock.routeStops = _mock.routeStops.filter(rs => !(rs.busLineId === routeStop.busLineId && rs.stopOrder === routeStop.stopOrder)); - _mock.routeStops.push(routeStop); - return res.status(201).json({ routeStop }); - } +app.post( + "/admin/bus-lines/:busLineId/stops", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { busLineId } = req.params; + const { stopId, stopOrder, estimatedArrivalMinutes } = req.body; - const result = await db.query(` + if (!stopId || stopOrder === undefined) { + return res.status(400).json({ error: "missing_required_fields" }); + } + + if (DEV_MOCK) { + const stop = _mock.stops.find((s) => String(s.id) === String(stopId)); + const routeStop = { + id: _mock.nextRouteStopId++, + busLineId: Number(busLineId), + stopId: Number(stopId), + stopOrder: Number(stopOrder), + estimatedArrivalMinutes: estimatedArrivalMinutes ?? null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + name: stop ? stop.name : `stop-${stopId}`, + latitude: stop ? stop.latitude : 0, + longitude: stop ? stop.longitude : 0, + }; + // remove existing with same busLineId & stopOrder + _mock.routeStops = _mock.routeStops.filter( + (rs) => + !( + rs.busLineId === routeStop.busLineId && + rs.stopOrder === routeStop.stopOrder + ), + ); + _mock.routeStops.push(routeStop); + return res.status(201).json({ routeStop }); + } + + const result = await db.query( + ` WITH next_id AS ( SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.route_stops ) @@ -1438,110 +1854,132 @@ app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, a 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]); + `, + [busLineId, stopId, stopOrder, estimatedArrivalMinutes ?? null], + ); - const link = result.rows[0]; - res.status(201).json({ - 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); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); - -app.post('/admin/bus-lines/:busLineId/stops/reorder', authMiddleware, adminMiddleware, async (req, res) => { - const client = await db.pool.connect(); - try { - const { busLineId } = req.params; - const { routeStopIds } = req.body || {}; - - if (!Array.isArray(routeStopIds) || routeStopIds.length === 0) { - return res.status(400).json({ error: 'routeStopIds array required' }); + const link = result.rows[0]; + res.status(201).json({ + 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); + res.status(500).json({ error: "internal_error", details: err.message }); } + }, +); - const normalizedIds = routeStopIds.map(id => Number(id)).filter(id => Number.isFinite(id)); - if (normalizedIds.length !== routeStopIds.length) { - return res.status(400).json({ error: 'routeStopIds must contain only numeric ids' }); - } +app.post( + "/admin/bus-lines/:busLineId/stops/reorder", + authMiddleware, + adminMiddleware, + async (req, res) => { + const client = await db.pool.connect(); + try { + const { busLineId } = req.params; + const { routeStopIds } = req.body || {}; - if (DEV_MOCK) { - const busLineStops = _mock.routeStops - .filter(rs => String(rs.busLineId) === String(busLineId)) - .sort((a, b) => a.stopOrder - b.stopOrder); + if (!Array.isArray(routeStopIds) || routeStopIds.length === 0) { + return res.status(400).json({ error: "routeStopIds array required" }); + } + + const normalizedIds = routeStopIds + .map((id) => Number(id)) + .filter((id) => Number.isFinite(id)); + if (normalizedIds.length !== routeStopIds.length) { + return res + .status(400) + .json({ error: "routeStopIds must contain only numeric ids" }); + } + + if (DEV_MOCK) { + const busLineStops = _mock.routeStops + .filter((rs) => String(rs.busLineId) === String(busLineId)) + .sort((a, b) => a.stopOrder - b.stopOrder); + + const idSet = new Set(busLineStops.map((rs) => Number(rs.id))); + const sameCount = busLineStops.length === normalizedIds.length; + const sameIds = sameCount && normalizedIds.every((id) => idSet.has(id)); + if (!sameIds) { + return res + .status(400) + .json({ error: "routeStopIds must match current bus line stops" }); + } + + const updated = normalizedIds.map((routeStopId, index) => { + const stop = _mock.routeStops.find( + (rs) => + Number(rs.id) === routeStopId && + String(rs.busLineId) === String(busLineId), + ); + if (!stop) return null; + stop.stopOrder = index + 1; + stop.updatedAt = new Date().toISOString(); + return stop; + }); + + if (updated.some((item) => !item)) { + return res + .status(400) + .json({ error: "routeStopIds must match current bus line stops" }); + } + + _mock.routeStops = _mock.routeStops.map((routeStop) => { + const reordered = updated.find((item) => item.id === routeStop.id); + return reordered || routeStop; + }); + + return res.json({ + routeStops: updated, + }); + } + + const current = await client.query( + `SELECT id FROM transit.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)); - const idSet = new Set(busLineStops.map(rs => Number(rs.id))); - const sameCount = busLineStops.length === normalizedIds.length; - const sameIds = sameCount && normalizedIds.every(id => idSet.has(id)); if (!sameIds) { - return res.status(400).json({ error: 'routeStopIds must match current bus line stops' }); + return res + .status(400) + .json({ error: "routeStopIds must match current bus line stops" }); } - const updated = normalizedIds.map((routeStopId, index) => { - const stop = _mock.routeStops.find(rs => Number(rs.id) === routeStopId && String(rs.busLineId) === String(busLineId)); - if (!stop) return null; - stop.stopOrder = index + 1; - stop.updatedAt = new Date().toISOString(); - return stop; - }); + await client.query("BEGIN"); - if (updated.some(item => !item)) { - return res.status(400).json({ error: 'routeStopIds must match current bus line stops' }); + for (let index = 0; index < normalizedIds.length; index += 1) { + await client.query( + `UPDATE transit.route_stops + SET stop_order = $1, updated_at = NOW() + WHERE id = $2 AND bus_line_id = $3`, + [-(index + 1), normalizedIds[index], busLineId], + ); } - _mock.routeStops = _mock.routeStops.map(routeStop => { - const reordered = updated.find(item => item.id === routeStop.id); - return reordered || routeStop; - }); - - return res.json({ - routeStops: updated, - }); - } - - const current = await client.query( - `SELECT id FROM transit.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 res.status(400).json({ 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 transit.route_stops + for (let index = 0; index < normalizedIds.length; index += 1) { + await client.query( + `UPDATE transit.route_stops SET stop_order = $1, updated_at = NOW() WHERE id = $2 AND bus_line_id = $3`, - [-(index + 1), normalizedIds[index], busLineId] - ); - } + [index + 1, normalizedIds[index], busLineId], + ); + } - for (let index = 0; index < normalizedIds.length; index += 1) { - await client.query( - `UPDATE transit.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 + const result = await client.query( + `SELECT rs.id, rs.stop_order, s.name as stop_name, @@ -1552,238 +1990,274 @@ app.post('/admin/bus-lines/:busLineId/stops/reorder', authMiddleware, adminMiddl JOIN transit.stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = $1 ORDER BY rs.stop_order ASC`, - [busLineId] - ); + [busLineId], + ); - await client.query('COMMIT'); + await client.query("COMMIT"); - res.json({ - 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); - res.status(500).json({ error: 'internal_error', details: err.message }); - } finally { - client.release(); - } -}); + res.json({ + 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); + res.status(500).json({ error: "internal_error", details: err.message }); + } finally { + client.release(); + } + }, +); // ============================================ // ADMIN - Delete a stop // ============================================ -app.delete('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { stopId } = req.params; +app.delete( + "/admin/stops/:stopId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { stopId } = req.params; - if (!stopId || !Number.isFinite(Number(stopId))) { - return res.status(400).json({ error: 'invalid_stop_id' }); - } - - if (DEV_MOCK) { - const index = _mock.stops.findIndex(s => Number(s.id) === Number(stopId)); - if (index === -1) { - return res.status(404).json({ error: 'stop_not_found' }); + if (!stopId || !Number.isFinite(Number(stopId))) { + return res.status(400).json({ error: "invalid_stop_id" }); } - const deletedStop = _mock.stops.splice(index, 1)[0]; - return res.json({ success: true, stop: deletedStop }); - } - const result = await db.query( - `DELETE FROM transit.stops WHERE id = $1 RETURNING id, name`, - [stopId] - ); + if (DEV_MOCK) { + const index = _mock.stops.findIndex( + (s) => Number(s.id) === Number(stopId), + ); + if (index === -1) { + return res.status(404).json({ error: "stop_not_found" }); + } + const deletedStop = _mock.stops.splice(index, 1)[0]; + return res.json({ success: true, stop: deletedStop }); + } - if (result.rows.length === 0) { - return res.status(404).json({ error: 'stop_not_found' }); - } + const result = await db.query( + `DELETE FROM transit.stops WHERE id = $1 RETURNING id, name`, + [stopId], + ); - res.json({ success: true, stop: result.rows[0] }); - } catch (err) { - console.error(err); - if (err.code === '23503') { - return res.status(409).json({ error: 'foreign_key_violation', message: 'This stop is referenced by route_stops' }); + if (result.rows.length === 0) { + return res.status(404).json({ error: "stop_not_found" }); + } + + res.json({ success: true, stop: result.rows[0] }); + } catch (err) { + console.error(err); + if (err.code === "23503") { + return res.status(409).json({ + error: "foreign_key_violation", + message: "This stop is referenced by route_stops", + }); + } + res.status(500).json({ error: "internal_error", details: err.message }); } - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + }, +); // ============================================ // ADMIN - Update a stop // ============================================ -app.patch('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { stopId } = req.params; - const { name, latitude, longitude } = req.body; +app.patch( + "/admin/stops/:stopId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { stopId } = req.params; + const { name, latitude, longitude } = req.body; - if (!stopId || !Number.isFinite(Number(stopId))) { - return res.status(400).json({ error: 'invalid_stop_id' }); - } + if (!stopId || !Number.isFinite(Number(stopId))) { + return res.status(400).json({ error: "invalid_stop_id" }); + } - if (DEV_MOCK) { - const stop = _mock.stops.find(s => Number(s.id) === Number(stopId)); - if (!stop) return res.status(404).json({ error: 'stop_not_found' }); - if (name !== undefined) stop.name = name; - if (latitude !== undefined) stop.latitude = parseFloat(latitude); - if (longitude !== undefined) stop.longitude = parseFloat(longitude); - stop.updatedAt = new Date().toISOString(); - return res.json({ stop }); - } + if (DEV_MOCK) { + const stop = _mock.stops.find((s) => Number(s.id) === Number(stopId)); + if (!stop) return res.status(404).json({ error: "stop_not_found" }); + if (name !== undefined) stop.name = name; + if (latitude !== undefined) stop.latitude = parseFloat(latitude); + if (longitude !== undefined) stop.longitude = parseFloat(longitude); + stop.updatedAt = new Date().toISOString(); + return res.json({ stop }); + } - const result = await db.query( - `UPDATE transit.stops + const result = await db.query( + `UPDATE transit.stops SET name = COALESCE($1, name), latitude = COALESCE($2, latitude), longitude = COALESCE($3, longitude), updated_at = NOW() WHERE id = $4 - RETURNING id, name, latitude, longitude, created_at, updated_at` - , [name ?? null, latitude ?? null, longitude ?? null, stopId]); + RETURNING id, name, latitude, longitude, created_at, updated_at`, + [name ?? null, latitude ?? null, longitude ?? null, stopId], + ); - if (!result.rows[0]) return res.status(404).json({ error: 'stop_not_found' }); + if (!result.rows[0]) + return res.status(404).json({ error: "stop_not_found" }); - const stop = result.rows[0]; - res.json({ stop: { - id: stop.id, - name: stop.name, - latitude: parseFloat(stop.latitude), - longitude: parseFloat(stop.longitude), - createdAt: stop.created_at, - updatedAt: stop.updated_at, - }}); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + const stop = result.rows[0]; + res.json({ + stop: { + id: stop.id, + name: stop.name, + latitude: parseFloat(stop.latitude), + longitude: parseFloat(stop.longitude), + createdAt: stop.created_at, + updatedAt: stop.updated_at, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); // ============================================ // ADMIN - Delete a bus line // ============================================ -app.delete('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async (req, res) => { - const client = await db.pool.connect(); - try { - const { busLineId } = req.params; +app.delete( + "/admin/bus-lines/:busLineId", + authMiddleware, + adminMiddleware, + async (req, res) => { + const client = await db.pool.connect(); + try { + const { busLineId } = req.params; - if (!busLineId || !Number.isFinite(Number(busLineId))) { - return res.status(400).json({ error: 'invalid_bus_line_id' }); - } - - if (DEV_MOCK) { - const lineIndex = _mock.busLines.findIndex(bl => Number(bl.id) === Number(busLineId)); - if (lineIndex === -1) { - return res.status(404).json({ error: 'bus_line_not_found' }); + if (!busLineId || !Number.isFinite(Number(busLineId))) { + return res.status(400).json({ error: "invalid_bus_line_id" }); } - const deletedLine = _mock.busLines.splice(lineIndex, 1)[0]; - // Also delete route_stops for this line - _mock.routeStops = _mock.routeStops.filter(rs => String(rs.busLineId) !== String(busLineId)); - return res.json({ success: true, busLine: deletedLine }); + + if (DEV_MOCK) { + const lineIndex = _mock.busLines.findIndex( + (bl) => Number(bl.id) === Number(busLineId), + ); + if (lineIndex === -1) { + return res.status(404).json({ error: "bus_line_not_found" }); + } + const deletedLine = _mock.busLines.splice(lineIndex, 1)[0]; + // Also delete route_stops for this line + _mock.routeStops = _mock.routeStops.filter( + (rs) => String(rs.busLineId) !== String(busLineId), + ); + return res.json({ success: true, busLine: deletedLine }); + } + + await client.query("BEGIN"); + + // Delete associated route_stops first + await client.query( + `DELETE FROM transit.route_stops WHERE bus_line_id = $1`, + [busLineId], + ); + + // Delete the bus line + const result = await client.query( + `DELETE FROM transit.bus_lines WHERE id = $1 RETURNING id, line_number`, + [busLineId], + ); + + if (result.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "bus_line_not_found" }); + } + + await client.query("COMMIT"); + res.json({ success: true, busLine: result.rows[0] }); + } catch (err) { + await client.query("ROLLBACK").catch(() => {}); + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } finally { + client.release(); } - - await client.query('BEGIN'); - - // Delete associated route_stops first - await client.query( - `DELETE FROM transit.route_stops WHERE bus_line_id = $1`, - [busLineId] - ); - - // Delete the bus line - const result = await client.query( - `DELETE FROM transit.bus_lines WHERE id = $1 RETURNING id, line_number`, - [busLineId] - ); - - if (result.rows.length === 0) { - await client.query('ROLLBACK'); - return res.status(404).json({ error: 'bus_line_not_found' }); - } - - await client.query('COMMIT'); - res.json({ success: true, busLine: result.rows[0] }); - } catch (err) { - await client.query('ROLLBACK').catch(() => {}); - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } finally { - client.release(); - } -}); + }, +); // ============================================ // ADMIN - Delete a route_stop from bus line // ============================================ -app.delete('/admin/bus-lines/:busLineId/stops/:routeStopId', authMiddleware, adminMiddleware, async (req, res) => { - const client = await db.pool.connect(); - try { - const { busLineId, routeStopId } = req.params; +app.delete( + "/admin/bus-lines/:busLineId/stops/:routeStopId", + authMiddleware, + adminMiddleware, + async (req, res) => { + const client = await db.pool.connect(); + try { + const { busLineId, routeStopId } = req.params; - if (!busLineId || !Number.isFinite(Number(busLineId))) { - return res.status(400).json({ error: 'invalid_bus_line_id' }); - } - - if (!routeStopId || !Number.isFinite(Number(routeStopId))) { - return res.status(400).json({ error: 'invalid_route_stop_id' }); - } - - if (DEV_MOCK) { - const index = _mock.routeStops.findIndex( - rs => Number(rs.id) === Number(routeStopId) && String(rs.busLineId) === String(busLineId) - ); - if (index === -1) { - return res.status(404).json({ error: 'route_stop_not_found' }); + if (!busLineId || !Number.isFinite(Number(busLineId))) { + return res.status(400).json({ error: "invalid_bus_line_id" }); } - const deletedRouteStop = _mock.routeStops.splice(index, 1)[0]; - return res.json({ success: true, routeStop: deletedRouteStop }); - } - await client.query('BEGIN'); + if (!routeStopId || !Number.isFinite(Number(routeStopId))) { + return res.status(400).json({ error: "invalid_route_stop_id" }); + } - // Find and delete the route_stop - const result = await client.query( - `DELETE FROM transit.route_stops + if (DEV_MOCK) { + const index = _mock.routeStops.findIndex( + (rs) => + Number(rs.id) === Number(routeStopId) && + String(rs.busLineId) === String(busLineId), + ); + if (index === -1) { + return res.status(404).json({ error: "route_stop_not_found" }); + } + const deletedRouteStop = _mock.routeStops.splice(index, 1)[0]; + return res.json({ success: true, routeStop: deletedRouteStop }); + } + + await client.query("BEGIN"); + + // Find and delete the route_stop + const result = await client.query( + `DELETE FROM transit.route_stops WHERE id = $1 AND bus_line_id = $2 RETURNING id, stop_order`, - [routeStopId, busLineId] - ); + [routeStopId, busLineId], + ); - if (result.rows.length === 0) { - await client.query('ROLLBACK'); - return res.status(404).json({ error: 'route_stop_not_found' }); - } + if (result.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "route_stop_not_found" }); + } - const deletedOrder = result.rows[0].stop_order; + const deletedOrder = result.rows[0].stop_order; - // Re-order remaining stops - await client.query( - `UPDATE transit.route_stops + // Re-order remaining stops + await client.query( + `UPDATE transit.route_stops SET stop_order = stop_order - 1, updated_at = NOW() WHERE bus_line_id = $1 AND stop_order > $2`, - [busLineId, deletedOrder] - ); + [busLineId, deletedOrder], + ); - await client.query('COMMIT'); - res.json({ success: true, routeStop: result.rows[0] }); - } catch (err) { - await client.query('ROLLBACK').catch(() => {}); - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } finally { - client.release(); - } -}); + await client.query("COMMIT"); + res.json({ success: true, routeStop: result.rows[0] }); + } catch (err) { + await client.query("ROLLBACK").catch(() => {}); + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } finally { + client.release(); + } + }, +); // ============================================ // NAVIGATION - Save calculated route // ============================================ -app.post('/navigation/route', authMiddleware, async (req, res) => { +app.post("/navigation/route", authMiddleware, async (req, res) => { try { const { routeId, @@ -1796,11 +2270,20 @@ app.post('/navigation/route', authMiddleware, async (req, res) => { geojson, } = req.body; - if (!routeId || !startLat || !startLon || !endLat || !endLon || !totalDistance || !totalDuration) { - return res.status(400).json({ error: 'missing_required_fields' }); + if ( + !routeId || + !startLat || + !startLon || + !endLat || + !endLon || + !totalDistance || + !totalDuration + ) { + return res.status(400).json({ error: "missing_required_fields" }); } - const result = await db.query(` + const result = await db.query( + ` INSERT INTO navigation_routes (route_id, start_lat, start_lon, end_lat, end_lon, total_distance_meters, total_duration_seconds, polyline_geojson) @@ -1814,67 +2297,80 @@ app.post('/navigation/route', authMiddleware, async (req, res) => { total_duration_seconds = $7, polyline_geojson = $8 RETURNING * - `, [ - routeId, - startLat, - startLon, - endLat, - endLon, - totalDistance, - totalDuration, - JSON.stringify(geojson), - ]); + `, + [ + routeId, + startLat, + startLon, + endLat, + endLon, + totalDistance, + totalDuration, + JSON.stringify(geojson), + ], + ); res.json({ route: result.rows[0] }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // GPS TRACKING - Record current position // ============================================ -app.post('/gps/track', authMiddleware, async (req, res) => { +app.post("/gps/track", authMiddleware, async (req, res) => { try { const { routeId, latitude, longitude, speed, heading, accuracy } = req.body; if (!routeId || !latitude || !longitude) { - return res.status(400).json({ error: 'missing_required_fields' }); + return res.status(400).json({ error: "missing_required_fields" }); } - await db.query(` + await db.query( + ` INSERT INTO gps_tracking (route_id, latitude, longitude, speed_kmh, heading, accuracy, recorded_at) VALUES ($1, $2, $3, $4, $5, $6, NOW()) - `, [routeId, latitude, longitude, speed || null, heading || null, accuracy || null]); + `, + [ + routeId, + latitude, + longitude, + speed || null, + heading || null, + accuracy || null, + ], + ); res.json({ ok: true }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // GPS TRACKING - Get route history // ============================================ -app.get('/gps/route/:routeId', authMiddleware, async (req, res) => { +app.get("/gps/route/:routeId", authMiddleware, async (req, res) => { try { const { routeId } = req.params; const userId = req.user.userId; // Verify user owns this route const routeCheck = await db.query( - 'SELECT id FROM routes WHERE id = $1 AND user_id = $2', - [routeId, userId] + "SELECT id FROM routes WHERE id = $1 AND user_id = $2", + [routeId, userId], ); if (routeCheck.rows.length === 0) { - return res.status(403).json({ error: 'unauthorized' }); + return res.status(403).json({ error: "unauthorized" }); } - const result = await db.query(` + const result = await db.query( + ` SELECT id, latitude, @@ -1886,9 +2382,11 @@ app.get('/gps/route/:routeId', authMiddleware, async (req, res) => { FROM gps_tracking WHERE route_id = $1 ORDER BY recorded_at ASC - `, [routeId]); + `, + [routeId], + ); - const positions = result.rows.map(row => ({ + const positions = result.rows.map((row) => ({ id: row.id, latitude: parseFloat(row.latitude), longitude: parseFloat(row.longitude), @@ -1901,172 +2399,283 @@ app.get('/gps/route/:routeId', authMiddleware, async (req, res) => { res.json({ positions }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // ROUTES - Update route status // ============================================ -app.patch('/routes/:routeId/status', authMiddleware, async (req, res) => { +app.patch("/routes/:routeId/status", authMiddleware, async (req, res) => { try { const { routeId } = req.params; const { status } = req.body; const userId = req.user.userId; - const validStatuses = ['scheduled', 'in_progress', 'completed', 'cancelled']; + const validStatuses = [ + "scheduled", + "in_progress", + "completed", + "cancelled", + ]; if (!validStatuses.includes(status)) { - return res.status(400).json({ error: 'invalid_status' }); + return res.status(400).json({ error: "invalid_status" }); } - const result = await db.query(` + const result = await db.query( + ` UPDATE transit.routes SET status = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3 RETURNING * - `, [status, routeId, userId]); + `, + [status, routeId, userId], + ); if (result.rows.length === 0) { - return res.status(404).json({ error: 'route_not_found' }); + return res.status(404).json({ error: "route_not_found" }); } res.json({ route: result.rows[0] }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // DRIVER - Update schedule status // ============================================ -app.patch('/driver/schedules/:scheduleId/status', authMiddleware, async (req, res) => { - try { - const { scheduleId } = req.params; - const { status } = req.body; - - const validStatuses = ['planned', 'in_progress', 'completed', 'cancelled', 'active']; - if (!validStatuses.includes(status)) { - return res.status(400).json({ error: 'invalid_status' }); - } - - const normalizedStatus = status === 'active' ? 'in_progress' : status; - - // Fetch the target schedule row to detect recurring templates - const existing = await db.query(`SELECT * FROM transit.schedules WHERE id = $1`, [scheduleId]); - if (existing.rows.length === 0) { - return res.status(404).json({ error: 'schedule_not_found' }); - } - - const scheduleRow = existing.rows[0]; - const weekdays = parseWeekdaysValue(scheduleRow.weekdays); - - // Allow updating a specific occurrence of a recurring template by passing { date: 'YYYY-MM-DD' } - const requestedDate = req.body.date || null; // optional date key - - if (weekdays && weekdays.length > 0) { - // This is a recurring template. If a date was provided, or the template occurs today, operate on an instance. - let targetDateKey = requestedDate; - if (!targetDateKey) { - const todayKey = new Date().toISOString().slice(0, 10); - if (scheduleOccursOnDate(scheduleRow, todayKey)) { - targetDateKey = todayKey; - } +app.patch( + "/driver/weekly-schedules/:scheduleId/status", + authMiddleware, + async (req, res) => { + try { + const scheduleId = Number(req.params.scheduleId); + const { status } = req.body; + const validStatuses = [ + "planned", + "in_progress", + "completed", + "cancelled", + "active", + ]; + if (!Number.isInteger(scheduleId) || !validStatuses.includes(status)) { + return res.status(400).json({ error: "invalid_status" }); } - if (targetDateKey) { - // Build occurrence start/end based on template times - 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() - ); + const normalizedStatus = status === "active" ? "in_progress" : status; + const result = await db.query( + ` + UPDATE transit.schedules s + SET status = $1, updated_at = NOW() + WHERE s.id = $2 + AND EXISTS ( + SELECT 1 FROM transit.shifts sh + WHERE sh.schedule_id = s.id AND sh.driver_id = $3 + ) + RETURNING s.id, s.status + `, + [normalizedStatus, scheduleId, req.user.userId], + ); - let occurrenceEnd = null; - if (Number.isFinite(templateEnd.getTime()) && Number.isFinite(templateStart.getTime())) { - const duration = templateEnd.getTime() - templateStart.getTime(); - occurrenceEnd = new Date(occurrenceStart.getTime() + duration); + if (result.rows.length === 0) + return res.status(404).json({ error: "schedule_not_found" }); + res.json({ + schedule: { + id: Number(result.rows[0].id), + status: result.rows[0].status, + }, + }); + } catch (err) { + console.error("PATCH /driver/weekly-schedules/:id/status error:", err); + res.status(500).json({ error: "internal_error" }); + } + }, +); + +app.patch( + "/driver/schedules/:scheduleId/status", + authMiddleware, + async (req, res) => { + try { + const { scheduleId } = req.params; + const { status } = req.body; + + const validStatuses = [ + "planned", + "in_progress", + "completed", + "cancelled", + "active", + ]; + if (!validStatuses.includes(status)) { + return res.status(400).json({ error: "invalid_status" }); + } + + const normalizedStatus = status === "active" ? "in_progress" : status; + + // Fetch the target schedule row to detect recurring templates + const existing = await db.query( + `SELECT * FROM transit.schedules WHERE id = $1`, + [scheduleId], + ); + if (existing.rows.length === 0) { + return res.status(404).json({ error: "schedule_not_found" }); + } + + const scheduleRow = existing.rows[0]; + const weekdays = parseWeekdaysValue(scheduleRow.weekdays); + + // Allow updating a specific occurrence of a recurring template by passing { date: 'YYYY-MM-DD' } + const requestedDate = req.body.date || null; // optional date key + + if (weekdays && weekdays.length > 0) { + // This is a recurring template. If a date was provided, or the template occurs today, operate on an instance. + let targetDateKey = requestedDate; + if (!targetDateKey) { + const todayKey = new Date().toISOString().slice(0, 10); + if (scheduleOccursOnDate(scheduleRow, todayKey)) { + targetDateKey = todayKey; + } } - // Check if a concrete instance already exists for this template/date - const existingInstance = await db.query( - `SELECT * FROM transit.schedules WHERE template_id = $1 AND DATE(start_time) = $2`, - [scheduleId, targetDateKey] - ); + if (targetDateKey) { + // Build occurrence start/end based on template times + 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(), + ); - if (existingInstance.rows.length > 0) { - const upd = await db.query(` + let occurrenceEnd = null; + if ( + Number.isFinite(templateEnd.getTime()) && + Number.isFinite(templateStart.getTime()) + ) { + const duration = templateEnd.getTime() - templateStart.getTime(); + occurrenceEnd = new Date(occurrenceStart.getTime() + duration); + } + + // Check if a concrete instance already exists for this template/date + const existingInstance = await db.query( + `SELECT * FROM transit.schedules WHERE template_id = $1 AND DATE(start_time) = $2`, + [scheduleId, targetDateKey], + ); + + if (existingInstance.rows.length > 0) { + const upd = await db.query( + ` UPDATE transit.schedules SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING * - `, [normalizedStatus, existingInstance.rows[0].id]); - return res.json({ schedule: upd.rows[0] }); - } + `, + [normalizedStatus, existingInstance.rows[0].id], + ); + return res.json({ schedule: upd.rows[0] }); + } - // Insert a new instance derived from template - const hasTemplateIdCol = await db.query(` + // Insert a new instance derived from template + 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(` + let insertResult; + if (hasTemplateIdCol.rows.length > 0) { + insertResult = await db.query( + ` WITH next_id AS (SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules) INSERT INTO transit.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(` + `, + [ + 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 transit.schedules) INSERT INTO transit.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]); + `, + [ + scheduleRow.bus_line_id, + scheduleRow.bus_id, + scheduleRow.driver_id, + occurrenceStart.toISOString(), + occurrenceEnd ? occurrenceEnd.toISOString() : null, + normalizedStatus, + ], + ); + } + + return res.json({ schedule: insertResult.rows[0] }); } - - return res.json({ schedule: insertResult.rows[0] }); } - } - // Fallback: update the schedule row directly (one-time or when no occurrence date is inferred) - const result = await db.query(` + // Fallback: update the schedule row directly (one-time or when no occurrence date is inferred) + const result = await db.query( + ` UPDATE transit.schedules SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING * - `, [normalizedStatus, scheduleId]); + `, + [normalizedStatus, scheduleId], + ); - if (result.rows.length === 0) { - return res.status(404).json({ error: 'schedule_not_found' }); + if (result.rows.length === 0) { + return res.status(404).json({ error: "schedule_not_found" }); + } + + res.json({ schedule: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); } - - res.json({ schedule: result.rows[0] }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + }, +); // ============================================ // ADMIN - Get drivers for scheduling // ============================================ -app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => { +app.get("/admin/drivers", authMiddleware, adminMiddleware, async (req, res) => { try { if (DEV_MOCK) { - const drivers = _mock.drivers.slice().filter(d => d.status === 'active'); - return res.json({ - drivers: drivers.map(d => ({ + const drivers = _mock.drivers + .slice() + .filter((d) => d.status === "active"); + return res.json({ + drivers: drivers.map((d) => ({ ...d, - available: !checkDriverConflict(d.id, new Date().toISOString(), new Date(Date.now() + 24*60*60*1000).toISOString(), [], _mock.schedules || []) - })) + available: !checkDriverConflict( + d.id, + new Date().toISOString(), + new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + [], + _mock.schedules || [], + ), + })), }); } @@ -2078,23 +2687,35 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => { `); // Get all schedules to check for conflicts - const schedulesResult = await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`); - const schedules = schedulesResult.rows.map(row => ({ + const schedulesResult = await db.query( + `SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`, + ); + const schedules = schedulesResult.rows.map((row) => ({ ...row, weekdays: parseWeekdaysValue(row.weekdays), })); res.json({ - drivers: result.rows.map(row => { + drivers: result.rows.map((row) => { const driverId = row.id; const now = new Date(); const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); - const isAvailable = !checkDriverConflict(driverId, now.toISOString(), tomorrow.toISOString(), [], schedules); - + const isAvailable = !checkDriverConflict( + driverId, + now.toISOString(), + tomorrow.toISOString(), + [], + schedules, + ); + return { id: row.id, - name: (String(row.firstname || '') + ' ' + String(row.lastname || '')).trim(), + name: ( + String(row.firstname || "") + + " " + + String(row.lastname || "") + ).trim(), email: row.email, phone: row.phone, job: row.job, @@ -2106,65 +2727,389 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => { }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // ADMIN - Manage schedules (timetable) // ============================================ -app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => { - try { - const templatesOnly = String(req.query.templates || req.query.includeTemplates || '').toLowerCase() === 'true' || String(req.query.templates || req.query.includeTemplates || '') === '1'; +app.get( + "/admin/shift-drivers", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const result = await db.query(` + SELECT id, usercode + FROM workers.users + ORDER BY usercode ASC + `); + res.json({ + drivers: result.rows.map((row) => ({ + id: Number(row.id), + name: String(row.usercode), + })), + }); + } catch (err) { + console.error("GET /admin/shift-drivers error:", err); + res.status(500).json({ error: "internal_error" }); + } + }, +); - if (DEV_MOCK) { - 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, +app.get( + "/admin/weekly-schedules", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const result = await db.query(` + SELECT s.id, s.bus_line_id, s.start_time, s.end_time, s.status, s.day_of_week, + bl.line_number, bl.start_stop, bl.end_stop, + sh.id AS shift_id, sh.driver_id, u.usercode AS driver_name + FROM transit.schedules s + JOIN transit.bus_lines bl ON bl.id = s.bus_line_id + LEFT JOIN transit.shifts sh ON sh.schedule_id = s.id + LEFT JOIN workers.users u ON u.id = sh.driver_id + ORDER BY s.day_of_week ASC, s.start_time ASC, s.id ASC + `); + res.json({ + schedules: result.rows.map((row) => ({ + id: Number(row.id), + busLineId: Number(row.bus_line_id), + lineNumber: row.line_number, + startStop: row.start_stop, + endStop: row.end_stop, + startTime: row.start_time, + endTime: row.end_time, + status: row.status, + dayOfWeek: Number(row.day_of_week), + shiftId: row.shift_id ? Number(row.shift_id) : null, + driverId: row.driver_id ? Number(row.driver_id) : null, + driverName: row.driver_name || null, + })), + }); + } catch (err) { + console.error("GET /admin/weekly-schedules error:", err); + res.status(500).json({ error: "internal_error" }); + } + }, +); + +app.post( + "/admin/weekly-schedules", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const busLineId = Number(req.body?.busLineId); + const dayOfWeek = Number(req.body?.dayOfWeek); + const startTime = String(req.body?.startTime || ""); + const endTime = String(req.body?.endTime || ""); + const driverId = req.body?.driverId ? Number(req.body.driverId) : null; + + if ( + !Number.isInteger(busLineId) || + !Number.isInteger(dayOfWeek) || + dayOfWeek < 0 || + dayOfWeek > 6 || + !startTime || + !endTime + ) { + return res.status(400).json({ + error: "busLineId, dayOfWeek, startTime and endTime are required", }); } - const rawSchedules = (_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 { - ...s, - 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: s.driverId || null, - estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60, - }; - }); + const created = await db.query( + ` + INSERT INTO transit.schedules (bus_line_id, start_time, end_time, status, day_of_week) + VALUES ($1, $2::time, $3::time, 'planned', $4) + RETURNING id, bus_line_id, start_time, end_time, status, day_of_week + `, + [busLineId, startTime, endTime, dayOfWeek], + ); - const schedules = (templatesOnly ? rawSchedules : expandRecurringScheduleRows(rawSchedules)) - .filter(row => scheduleMatchesFilters(row, parseScheduleFilters(req.query), routeStopsByLine)) - .map(s => ({ - ...s, - departureTimes: calculateDepartureTimes(s.startTime, s.busLineId, _mock), - })) - .sort((a, b) => new Date(a.startTime) - new Date(b.startTime)); - - return res.json({ schedules }); + const schedule = created.rows[0]; + if (driverId) { + await db.query( + ` + INSERT INTO transit.shifts (schedule_id, driver_id) + VALUES ($1, $2) + `, + [schedule.id, driverId], + ); + } + + res.status(201).json({ + schedule: { + id: Number(schedule.id), + busLineId: Number(schedule.bus_line_id), + startTime: schedule.start_time, + endTime: schedule.end_time, + status: schedule.status, + dayOfWeek: Number(schedule.day_of_week), + driverId, + }, + }); + } catch (err) { + console.error("POST /admin/weekly-schedules error:", err); + res.status(500).json({ error: "internal_error" }); + } + }, +); + +app.post( + "/admin/weekly-schedules/bulk", + authMiddleware, + adminMiddleware, + async (req, res) => { + const busLineId = Number(req.body?.busLineId); + const dayOfWeeks = Array.isArray(req.body?.dayOfWeeks) + ? req.body.dayOfWeeks + .map(Number) + .filter((day) => Number.isInteger(day) && day >= 0 && day <= 6) + : []; + const startTimes = Array.isArray(req.body?.startTimes) + ? [...new Set(req.body.startTimes.map(String).filter(Boolean))] + : []; + const driverId = req.body?.driverId ? Number(req.body.driverId) : null; + + if ( + !Number.isInteger(busLineId) || + dayOfWeeks.length === 0 || + startTimes.length === 0 + ) { + return res + .status(400) + .json({ error: "busLineId, dayOfWeeks and startTimes are required" }); } - const hasWeekdaysColumn = await db.query(` + const client = await db.pool.connect(); + try { + await client.query("BEGIN"); + const line = await client.query( + "SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1", + [busLineId], + ); + if (line.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "bus_line_not_found" }); + } + + const duration = Number(line.rows[0].estimated_duration_minutes) || 60; + const created = []; + for (const dayOfWeek of dayOfWeeks) { + for (const startTime of startTimes) { + const result = await client.query( + `INSERT INTO transit.schedules + (bus_line_id, start_time, end_time, status, day_of_week) + VALUES ($1, $2::time, ($2::time + ($3::text || ' minutes')::interval)::time, 'planned', $4) + RETURNING id, bus_line_id, start_time, end_time, status, day_of_week`, + [busLineId, startTime, duration, dayOfWeek], + ); + const schedule = result.rows[0]; + if (driverId) { + await client.query( + "INSERT INTO transit.shifts (schedule_id, driver_id) VALUES ($1, $2)", + [schedule.id, driverId], + ); + } + created.push(schedule); + } + } + + await client.query("COMMIT"); + res.status(201).json({ + createdCount: created.length, + schedules: created.map((schedule) => ({ + id: Number(schedule.id), + busLineId: Number(schedule.bus_line_id), + startTime: schedule.start_time, + endTime: schedule.end_time, + status: schedule.status, + dayOfWeek: Number(schedule.day_of_week), + driverId, + })), + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("POST /admin/weekly-schedules/bulk error:", err); + res.status(500).json({ error: "internal_error" }); + } finally { + client.release(); + } + }, +); + +app.put( + "/admin/weekly-schedules/:scheduleId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const scheduleId = Number(req.params.scheduleId); + const busLineId = Number(req.body?.busLineId); + const dayOfWeek = Number(req.body?.dayOfWeek); + const startTime = String(req.body?.startTime || ""); + const endTime = String(req.body?.endTime || ""); + if ( + !Number.isInteger(scheduleId) || + !Number.isInteger(busLineId) || + !Number.isInteger(dayOfWeek) || + dayOfWeek < 0 || + dayOfWeek > 6 || + !startTime || + !endTime + ) { + return res.status(400).json({ error: "invalid_schedule" }); + } + + const result = await db.query( + ` + UPDATE transit.schedules + SET bus_line_id = $2, start_time = $3::time, end_time = $4::time, + day_of_week = $5, updated_at = NOW() + WHERE id = $1 + RETURNING id, bus_line_id, start_time, end_time, status, day_of_week + `, + [scheduleId, busLineId, startTime, endTime, dayOfWeek], + ); + if (result.rows.length === 0) + return res.status(404).json({ error: "schedule_not_found" }); + res.json({ schedule: result.rows[0] }); + } catch (err) { + console.error("PUT /admin/weekly-schedules error:", err); + res.status(500).json({ error: "internal_error" }); + } + }, +); + +app.put( + "/admin/weekly-schedules/:scheduleId/shift", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const scheduleId = Number(req.params.scheduleId); + const driverId = req.body?.driverId ? Number(req.body.driverId) : null; + if (!Number.isInteger(scheduleId)) + return res.status(400).json({ error: "invalid_schedule_id" }); + await db.query("DELETE FROM transit.shifts WHERE schedule_id = $1", [ + scheduleId, + ]); + if (driverId) { + await db.query( + "INSERT INTO transit.shifts (schedule_id, driver_id) VALUES ($1, $2)", + [scheduleId, driverId], + ); + } + res.json({ scheduleId, driverId }); + } catch (err) { + console.error("PUT /admin/weekly-schedules/:id/shift error:", err); + res.status(500).json({ error: "internal_error" }); + } + }, +); + +app.delete( + "/admin/weekly-schedules/:scheduleId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const scheduleId = Number(req.params.scheduleId); + if (!Number.isInteger(scheduleId)) + return res.status(400).json({ error: "invalid_schedule_id" }); + const result = await db.query( + "DELETE FROM transit.schedules WHERE id = $1 RETURNING id", + [scheduleId], + ); + if (result.rows.length === 0) + return res.status(404).json({ error: "schedule_not_found" }); + res.json({ success: true, scheduleId }); + } catch (err) { + console.error("DELETE /admin/weekly-schedules error:", err); + res.status(500).json({ error: "internal_error" }); + } + }, +); + +app.get( + "/admin/schedules", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const templatesOnly = + String( + req.query.templates || req.query.includeTemplates || "", + ).toLowerCase() === "true" || + String(req.query.templates || req.query.includeTemplates || "") === "1"; + + if (DEV_MOCK) { + 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 rawSchedules = (_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 { + ...s, + 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: s.driverId || null, + estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60, + }; + }); + + const schedules = ( + templatesOnly + ? rawSchedules + : expandRecurringScheduleRows(rawSchedules) + ) + .filter((row) => + scheduleMatchesFilters( + row, + parseScheduleFilters(req.query), + routeStopsByLine, + ), + ) + .map((s) => ({ + ...s, + departureTimes: calculateDepartureTimes( + s.startTime, + s.busLineId, + _mock, + ), + })) + .sort((a, b) => new Date(a.startTime) - new Date(b.startTime)); + + return res.json({ schedules }); + } + + const hasWeekdaysColumn = await db.query(` SELECT 1 FROM information_schema.columns WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays' `); - const baseSelect = ` + const baseSelect = ` SELECT s.id, s.bus_line_id, @@ -2180,31 +3125,33 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => b.license_plate, TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) as driver_name`; - const result = hasWeekdaysColumn.rows.length > 0 - ? await db.query(`${baseSelect}, s.weekdays as weekdays + const result = + hasWeekdaysColumn.rows.length > 0 + ? await db.query(`${baseSelect}, s.weekdays as weekdays FROM transit.schedules s LEFT JOIN transit.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 ORDER BY s.start_time DESC`) - : await db.query(`${baseSelect}, NULL as weekdays + : await db.query(`${baseSelect}, NULL as weekdays FROM transit.schedules s LEFT JOIN transit.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 ORDER BY s.start_time DESC`); - const lineIds = Array.from( - new Set( - result.rows - .map(row => Number(row.bus_line_id)) - .filter(Number.isFinite) - ) - ); + const lineIds = Array.from( + new Set( + result.rows + .map((row) => Number(row.bus_line_id)) + .filter(Number.isFinite), + ), + ); - const routeStopsByLine = new Map(); - if (lineIds.length > 0) { - const routeStopsResult = await db.query(` + const routeStopsByLine = new Map(); + if (lineIds.length > 0) { + const routeStopsResult = await db.query( + ` SELECT rs.bus_line_id, rs.stop_id, @@ -2215,98 +3162,125 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => JOIN transit.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]); + `, + [lineIds], + ); - for (const row of routeStopsResult.rows) { - const busLineId = Number(row.bus_line_id); - if (!routeStopsByLine.has(busLineId)) { - routeStopsByLine.set(busLineId, []); + 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); } - routeStopsByLine.get(busLineId).push(row); } + + const filters = parseScheduleFilters(req.query); + const rowSource = templatesOnly + ? result.rows + : expandRecurringScheduleRows(result.rows); + const expandedSchedules = rowSource.filter((row) => + scheduleMatchesFilters(row, filters, routeStopsByLine), + ); + + const schedules = expandedSchedules + .map((row) => mapAdminScheduleRow(row, routeStopsByLine)) + .sort((a, b) => new Date(a.startTime) - new Date(b.startTime)); + + res.json({ schedules }); + } catch (err) { + console.error("GET /admin/schedules error:", err); + res.status(500).json({ error: "internal_error", details: err.message }); } + }, +); - const filters = parseScheduleFilters(req.query); - const rowSource = templatesOnly ? result.rows : expandRecurringScheduleRows(result.rows) - const expandedSchedules = rowSource - .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); +app.post( + "/admin/schedules", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { busLineId, busId, driverId, startTime, weekdays } = req.body; - const schedules = expandedSchedules - .map(row => mapAdminScheduleRow(row, routeStopsByLine)) - .sort((a, b) => new Date(a.startTime) - new Date(b.startTime)); + if (!busLineId || !busId || !startTime) { + return res.status(400).json({ + error: + "missing_required_fields: busLineId, busId, startTime required", + }); + } - res.json({ schedules }); - } catch (err) { - console.error('GET /admin/schedules error:', err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + // Validate weekdays - should be array of day numbers (0-6) or empty for single schedule + const scheduleWeekdays = + Array.isArray(weekdays) && weekdays.length > 0 + ? weekdays.map((w) => Number(w)).filter((w) => w >= 0 && w <= 6) + : []; -app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { busLineId, busId, driverId, startTime, weekdays } = req.body; + if (DEV_MOCK) { + if (!_mock.schedules) _mock.schedules = []; - if (!busLineId || !busId || !startTime) { - return res.status(400).json({ error: 'missing_required_fields: busLineId, busId, startTime required' }); - } + // Find the bus line to get duration + const busLine = _mock.busLines.find((bl) => bl.id == busLineId); + if (!busLine) { + return res.status(404).json({ error: "bus_line_not_found" }); + } - // Validate weekdays - should be array of day numbers (0-6) or empty for single schedule - const scheduleWeekdays = Array.isArray(weekdays) && weekdays.length > 0 - ? weekdays.map(w => Number(w)).filter(w => w >= 0 && w <= 6) - : []; + const startDate = new Date(startTime); + const endDate = new Date(startDate); + endDate.setMinutes( + endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60), + ); - if (DEV_MOCK) { - if (!_mock.schedules) _mock.schedules = []; - - // Find the bus line to get duration - const busLine = _mock.busLines.find(bl => bl.id == busLineId); - if (!busLine) { - return res.status(404).json({ error: 'bus_line_not_found' }); + // Calculate departure times for stops + const departureTimes = calculateDepartureTimes( + startTime, + Number(busLineId), + _mock, + ); + + const schedule = { + id: _mock.nextScheduleId || 1, + busLineId, + busId, + driverId: driverId ? Number(driverId) : null, + startTime, + endTime: endDate.toISOString(), + weekdays: scheduleWeekdays, + departureTimes, + status: "planned", + createdAt: new Date().toISOString(), + }; + _mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1; + _mock.schedules.push(schedule); + return res.status(201).json({ schedule }); + } + + // Database mode (production) + const busLine = await db.query( + "SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1", + [busLineId], + ); + if (!busLine.rows[0]) { + return res.status(404).json({ error: "bus_line_not_found" }); } const startDate = new Date(startTime); const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60)); + endDate.setMinutes( + endDate.getMinutes() + + (busLine.rows[0].estimated_duration_minutes || 60), + ); - // Calculate departure times for stops - const departureTimes = calculateDepartureTimes(startTime, Number(busLineId), _mock); - - const schedule = { - id: (_mock.nextScheduleId || 1), - busLineId, - busId, - driverId: driverId ? Number(driverId) : null, - startTime, - endTime: endDate.toISOString(), - weekdays: scheduleWeekdays, - departureTimes, - status: 'planned', - createdAt: new Date().toISOString(), - }; - _mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1); - _mock.schedules.push(schedule); - return res.status(201).json({ schedule }); - } - - // Database mode (production) - const busLine = await db.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [busLineId]); - if (!busLine.rows[0]) { - return res.status(404).json({ error: 'bus_line_not_found' }); - } - - const startDate = new Date(startTime); - const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60)); - - // Check if weekdays column exists before including it in INSERT - const hasWeekdaysColumn = await db.query(` + // Check if weekdays column exists before including it in INSERT + const hasWeekdaysColumn = await db.query(` SELECT 1 FROM information_schema.columns WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays' `); - - let result; - if (hasWeekdaysColumn.rows.length > 0) { - result = await db.query(` + + let result; + if (hasWeekdaysColumn.rows.length > 0) { + result = await db.query( + ` WITH next_id AS ( SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules ) @@ -2314,9 +3288,19 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) = SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6 FROM next_id RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at - `, [busLineId, busId, driverId || null, startTime, endDate.toISOString(), JSON.stringify(scheduleWeekdays)]); - } else { - result = await db.query(` + `, + [ + busLineId, + busId, + driverId || null, + startTime, + endDate.toISOString(), + JSON.stringify(scheduleWeekdays), + ], + ); + } else { + result = await db.query( + ` WITH next_id AS ( SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules ) @@ -2324,106 +3308,144 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) = SELECT next_id.id, $1, $2, $3, $4, $5, 'planned' FROM next_id RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at - `, [busLineId, busId, driverId || null, startTime, endDate.toISOString()]); - } - - const schedule = result.rows[0]; - const departureTimes = calculateDepartureTimes(startTime, busLineId, _mock); - - const weekdaysResult = parseWeekdaysValue(schedule.weekdays); - - res.status(201).json({ - schedule: { - id: Number(schedule.id), - busLineId: Number(schedule.bus_line_id), - busId: Number(schedule.bus_id), - driverId: schedule.driver_id !== null && schedule.driver_id !== undefined ? Number(schedule.driver_id) : null, - startTime: schedule.start_time, - endTime: schedule.end_time, - weekdays: weekdaysResult, - departureTimes, - status: schedule.status, - createdAt: schedule.created_at, - }, - }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); - -app.put('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { scheduleId } = req.params; - const { busLineId, busId, driverId, startTime, weekdays } = req.body; - - if (!scheduleId || !Number.isFinite(Number(scheduleId))) { - return res.status(400).json({ error: 'invalid_schedule_id' }); - } - - if (!busLineId || !busId || !startTime) { - return res.status(400).json({ error: 'missing_required_fields: busLineId, busId, startTime required' }); - } - - const scheduleWeekdays = Array.isArray(weekdays) && weekdays.length > 0 - ? weekdays.map(w => Number(w)).filter(w => w >= 0 && w <= 6) - : []; - - if (DEV_MOCK) { - const index = (_mock.schedules || []).findIndex(s => Number(s.id) === Number(scheduleId)); - if (index === -1) { - return res.status(404).json({ error: 'schedule_not_found' }); + `, + [ + busLineId, + busId, + driverId || null, + startTime, + endDate.toISOString(), + ], + ); } - const busLine = _mock.busLines.find(bl => bl.id == busLineId); - if (!busLine) { - return res.status(404).json({ error: 'bus_line_not_found' }); + const schedule = result.rows[0]; + const departureTimes = calculateDepartureTimes( + startTime, + busLineId, + _mock, + ); + + const weekdaysResult = parseWeekdaysValue(schedule.weekdays); + + res.status(201).json({ + schedule: { + id: Number(schedule.id), + busLineId: Number(schedule.bus_line_id), + busId: Number(schedule.bus_id), + driverId: + schedule.driver_id !== null && schedule.driver_id !== undefined + ? Number(schedule.driver_id) + : null, + startTime: schedule.start_time, + endTime: schedule.end_time, + weekdays: weekdaysResult, + departureTimes, + status: schedule.status, + createdAt: schedule.created_at, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); + +app.put( + "/admin/schedules/:scheduleId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { scheduleId } = req.params; + const { busLineId, busId, driverId, startTime, weekdays } = req.body; + + if (!scheduleId || !Number.isFinite(Number(scheduleId))) { + return res.status(400).json({ error: "invalid_schedule_id" }); + } + + if (!busLineId || !busId || !startTime) { + return res.status(400).json({ + error: + "missing_required_fields: busLineId, busId, startTime required", + }); + } + + const scheduleWeekdays = + Array.isArray(weekdays) && weekdays.length > 0 + ? weekdays.map((w) => Number(w)).filter((w) => w >= 0 && w <= 6) + : []; + + if (DEV_MOCK) { + const index = (_mock.schedules || []).findIndex( + (s) => Number(s.id) === Number(scheduleId), + ); + if (index === -1) { + return res.status(404).json({ error: "schedule_not_found" }); + } + + const busLine = _mock.busLines.find((bl) => bl.id == busLineId); + if (!busLine) { + return res.status(404).json({ error: "bus_line_not_found" }); + } + + const startDate = new Date(startTime); + const endDate = new Date(startDate); + endDate.setMinutes( + endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60), + ); + + _mock.schedules[index] = { + ..._mock.schedules[index], + busLineId: Number(busLineId), + busId: Number(busId), + driverId: driverId ? Number(driverId) : null, + startTime, + endTime: endDate.toISOString(), + weekdays: scheduleWeekdays, + }; + + return res.json({ schedule: _mock.schedules[index] }); + } + + const existingResult = await db.query( + ` + SELECT id, status + FROM transit.schedules + WHERE id = $1 + `, + [scheduleId], + ); + + if (existingResult.rows.length === 0) { + return res.status(404).json({ error: "schedule_not_found" }); + } + + const busLine = await db.query( + "SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1", + [busLineId], + ); + if (!busLine.rows[0]) { + return res.status(404).json({ error: "bus_line_not_found" }); } const startDate = new Date(startTime); const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60)); + endDate.setMinutes( + endDate.getMinutes() + + (busLine.rows[0].estimated_duration_minutes || 60), + ); - _mock.schedules[index] = { - ..._mock.schedules[index], - busLineId: Number(busLineId), - busId: Number(busId), - driverId: driverId ? Number(driverId) : null, - startTime, - endTime: endDate.toISOString(), - weekdays: scheduleWeekdays, - }; - - return res.json({ schedule: _mock.schedules[index] }); - } - - const existingResult = await db.query(` - SELECT id, status - FROM transit.schedules - WHERE id = $1 - `, [scheduleId]); - - if (existingResult.rows.length === 0) { - return res.status(404).json({ error: 'schedule_not_found' }); - } - - const busLine = await db.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [busLineId]); - if (!busLine.rows[0]) { - return res.status(404).json({ error: 'bus_line_not_found' }); - } - - const startDate = new Date(startTime); - const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60)); - - const hasWeekdaysColumn = await db.query(` + const hasWeekdaysColumn = await db.query(` SELECT 1 FROM information_schema.columns WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays' `); - let result; - if (hasWeekdaysColumn.rows.length > 0) { - result = await db.query(` + let result; + if (hasWeekdaysColumn.rows.length > 0) { + result = await db.query( + ` UPDATE transit.schedules SET bus_line_id = $2, @@ -2435,9 +3457,20 @@ app.put('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async ( updated_at = NOW() WHERE id = $1 RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at - `, [scheduleId, busLineId, busId, driverId || null, startTime, endDate.toISOString(), JSON.stringify(scheduleWeekdays)]); - } else { - result = await db.query(` + `, + [ + scheduleId, + busLineId, + busId, + driverId || null, + startTime, + endDate.toISOString(), + JSON.stringify(scheduleWeekdays), + ], + ); + } else { + result = await db.query( + ` UPDATE transit.schedules SET bus_line_id = $2, @@ -2448,117 +3481,164 @@ app.put('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async ( updated_at = NOW() WHERE id = $1 RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at - `, [scheduleId, busLineId, busId, driverId || null, startTime, endDate.toISOString()]); - } - - const schedule = result.rows[0]; - return res.json({ - schedule: { - id: Number(schedule.id), - busLineId: Number(schedule.bus_line_id), - busId: Number(schedule.bus_id), - driverId: schedule.driver_id !== null && schedule.driver_id !== undefined ? Number(schedule.driver_id) : null, - startTime: schedule.start_time, - endTime: schedule.end_time, - weekdays: parseWeekdaysValue(schedule.weekdays), - status: schedule.status, - createdAt: schedule.created_at, - }, - }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); - -app.post('/admin/schedules/bulk', authMiddleware, adminMiddleware, async (req, res) => { - const requestedSchedules = Array.isArray(req.body?.schedules) ? req.body.schedules : []; - - if (requestedSchedules.length === 0) { - return res.status(400).json({ error: 'missing_required_fields: schedules array required' }); - } - - const normalizedSchedules = requestedSchedules.map((schedule, index) => { - const busLineId = Number(schedule?.busLineId); - const busId = Number(schedule?.busId); - const driverId = schedule?.driverId !== null && schedule?.driverId !== undefined && schedule?.driverId !== '' - ? Number(schedule.driverId) - : null; - const startTime = schedule?.startTime ? String(schedule.startTime) : ''; - const weekdays = Array.isArray(schedule?.weekdays) && schedule.weekdays.length > 0 - ? schedule.weekdays.map(day => Number(day)).filter(day => day >= 0 && day <= 6) - : []; - - if (!Number.isFinite(busLineId) || !Number.isFinite(busId) || !startTime) { - throw new Error(`invalid_schedule_entry:${index}`); - } - - return { busLineId, busId, driverId, startTime, weekdays }; - }); - - try { - if (DEV_MOCK) { - if (!_mock.schedules) _mock.schedules = []; - - const createdSchedules = []; - - for (const entry of normalizedSchedules) { - const busLine = _mock.busLines.find(bl => bl.id == entry.busLineId); - if (!busLine) { - return res.status(404).json({ error: 'bus_line_not_found' }); - } - - const startDate = new Date(entry.startTime); - const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60)); - - const departureTimes = calculateDepartureTimes(entry.startTime, Number(entry.busLineId), _mock); - - const schedule = { - id: (_mock.nextScheduleId || 1), - busLineId: entry.busLineId, - busId: entry.busId, - driverId: entry.driverId, - startTime: entry.startTime, - endTime: endDate.toISOString(), - weekdays: entry.weekdays, - departureTimes, - status: 'planned', - createdAt: new Date().toISOString(), - }; - - _mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1); - _mock.schedules.push(schedule); - createdSchedules.push(schedule); + `, + [ + scheduleId, + busLineId, + busId, + driverId || null, + startTime, + endDate.toISOString(), + ], + ); } - return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules }); + const schedule = result.rows[0]; + return res.json({ + schedule: { + id: Number(schedule.id), + busLineId: Number(schedule.bus_line_id), + busId: Number(schedule.bus_id), + driverId: + schedule.driver_id !== null && schedule.driver_id !== undefined + ? Number(schedule.driver_id) + : null, + startTime: schedule.start_time, + endTime: schedule.end_time, + weekdays: parseWeekdaysValue(schedule.weekdays), + status: schedule.status, + createdAt: schedule.created_at, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); + +app.post( + "/admin/schedules/bulk", + authMiddleware, + adminMiddleware, + async (req, res) => { + const requestedSchedules = Array.isArray(req.body?.schedules) + ? req.body.schedules + : []; + + if (requestedSchedules.length === 0) { + return res + .status(400) + .json({ error: "missing_required_fields: schedules array required" }); } - const dbClient = await db.pool.connect(); - try { - await dbClient.query('BEGIN'); + const normalizedSchedules = requestedSchedules.map((schedule, index) => { + const busLineId = Number(schedule?.busLineId); + const busId = Number(schedule?.busId); + const driverId = + schedule?.driverId !== null && + schedule?.driverId !== undefined && + schedule?.driverId !== "" + ? Number(schedule.driverId) + : null; + const startTime = schedule?.startTime ? String(schedule.startTime) : ""; + const weekdays = + Array.isArray(schedule?.weekdays) && schedule.weekdays.length > 0 + ? schedule.weekdays + .map((day) => Number(day)) + .filter((day) => day >= 0 && day <= 6) + : []; - const hasWeekdaysColumn = await dbClient.query(` + if ( + !Number.isFinite(busLineId) || + !Number.isFinite(busId) || + !startTime + ) { + throw new Error(`invalid_schedule_entry:${index}`); + } + + return { busLineId, busId, driverId, startTime, weekdays }; + }); + + try { + if (DEV_MOCK) { + if (!_mock.schedules) _mock.schedules = []; + + const createdSchedules = []; + + for (const entry of normalizedSchedules) { + const busLine = _mock.busLines.find((bl) => bl.id == entry.busLineId); + if (!busLine) { + return res.status(404).json({ error: "bus_line_not_found" }); + } + + const startDate = new Date(entry.startTime); + const endDate = new Date(startDate); + endDate.setMinutes( + endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60), + ); + + const departureTimes = calculateDepartureTimes( + entry.startTime, + Number(entry.busLineId), + _mock, + ); + + const schedule = { + id: _mock.nextScheduleId || 1, + busLineId: entry.busLineId, + busId: entry.busId, + driverId: entry.driverId, + startTime: entry.startTime, + endTime: endDate.toISOString(), + weekdays: entry.weekdays, + departureTimes, + status: "planned", + createdAt: new Date().toISOString(), + }; + + _mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1; + _mock.schedules.push(schedule); + createdSchedules.push(schedule); + } + + return res.status(201).json({ + createdCount: createdSchedules.length, + schedules: createdSchedules, + }); + } + + const dbClient = await db.pool.connect(); + try { + await dbClient.query("BEGIN"); + + const hasWeekdaysColumn = await dbClient.query(` SELECT 1 FROM information_schema.columns WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays' `); - const createdSchedules = []; + const createdSchedules = []; - for (const entry of normalizedSchedules) { - const busLine = await dbClient.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [entry.busLineId]); - if (!busLine.rows[0]) { - throw new Error('bus_line_not_found'); - } + for (const entry of normalizedSchedules) { + const busLine = await dbClient.query( + "SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1", + [entry.busLineId], + ); + if (!busLine.rows[0]) { + throw new Error("bus_line_not_found"); + } - const startDate = new Date(entry.startTime); - const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60)); + const startDate = new Date(entry.startTime); + const endDate = new Date(startDate); + endDate.setMinutes( + endDate.getMinutes() + + (busLine.rows[0].estimated_duration_minutes || 60), + ); - let result; - if (hasWeekdaysColumn.rows.length > 0) { - result = await dbClient.query(` + let result; + if (hasWeekdaysColumn.rows.length > 0) { + result = await dbClient.query( + ` WITH next_id AS ( SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules ) @@ -2566,9 +3646,19 @@ app.post('/admin/schedules/bulk', authMiddleware, adminMiddleware, async (req, r SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6 FROM next_id RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at - `, [entry.busLineId, entry.busId, entry.driverId, entry.startTime, endDate.toISOString(), JSON.stringify(entry.weekdays)]); - } else { - result = await dbClient.query(` + `, + [ + entry.busLineId, + entry.busId, + entry.driverId, + entry.startTime, + endDate.toISOString(), + JSON.stringify(entry.weekdays), + ], + ); + } else { + result = await dbClient.query( + ` WITH next_id AS ( SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules ) @@ -2576,83 +3666,115 @@ app.post('/admin/schedules/bulk', authMiddleware, adminMiddleware, async (req, r SELECT next_id.id, $1, $2, $3, $4, $5, 'planned' FROM next_id RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at - `, [entry.busLineId, entry.busId, entry.driverId, entry.startTime, endDate.toISOString()]); + `, + [ + entry.busLineId, + entry.busId, + entry.driverId, + entry.startTime, + endDate.toISOString(), + ], + ); + } + + const schedule = result.rows[0]; + createdSchedules.push({ + id: Number(schedule.id), + busLineId: Number(schedule.bus_line_id), + busId: Number(schedule.bus_id), + driverId: + schedule.driver_id !== null && schedule.driver_id !== undefined + ? Number(schedule.driver_id) + : null, + startTime: schedule.start_time, + endTime: schedule.end_time, + weekdays: parseWeekdaysValue(schedule.weekdays), + status: schedule.status, + createdAt: schedule.created_at, + }); } - const schedule = result.rows[0]; - createdSchedules.push({ - id: Number(schedule.id), - busLineId: Number(schedule.bus_line_id), - busId: Number(schedule.bus_id), - driverId: schedule.driver_id !== null && schedule.driver_id !== undefined ? Number(schedule.driver_id) : null, - startTime: schedule.start_time, - endTime: schedule.end_time, - weekdays: parseWeekdaysValue(schedule.weekdays), - status: schedule.status, - createdAt: schedule.created_at, + await dbClient.query("COMMIT"); + return res.status(201).json({ + createdCount: createdSchedules.length, + schedules: createdSchedules, }); + } catch (err) { + await dbClient.query("ROLLBACK"); + throw err; + } finally { + dbClient.release(); } - - await dbClient.query('COMMIT'); - return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules }); } catch (err) { - await dbClient.query('ROLLBACK'); - throw err; - } finally { - dbClient.release(); + console.error(err); + const errorMessage = + err.message === "bus_line_not_found" + ? "bus_line_not_found" + : err.message.startsWith("invalid_schedule_entry:") + ? err.message + : "internal_error"; + const statusCode = + errorMessage === "bus_line_not_found" + ? 404 + : errorMessage.startsWith("invalid_schedule_entry:") + ? 400 + : 500; + res + .status(statusCode) + .json({ error: errorMessage, details: err.message }); } - } catch (err) { - console.error(err); - const errorMessage = err.message === 'bus_line_not_found' - ? 'bus_line_not_found' - : err.message.startsWith('invalid_schedule_entry:') - ? err.message - : 'internal_error'; - const statusCode = errorMessage === 'bus_line_not_found' ? 404 : errorMessage.startsWith('invalid_schedule_entry:') ? 400 : 500; - res.status(statusCode).json({ error: errorMessage, details: err.message }); - } -}); + }, +); -app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { scheduleId } = req.params; +app.delete( + "/admin/schedules/:scheduleId", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { scheduleId } = req.params; - if (!scheduleId || !Number.isFinite(Number(scheduleId))) { - return res.status(400).json({ error: 'invalid_schedule_id' }); - } - - if (DEV_MOCK) { - if (!_mock.schedules) _mock.schedules = []; - const index = _mock.schedules.findIndex(s => Number(s.id) === Number(scheduleId)); - if (index === -1) { - return res.status(404).json({ error: 'schedule_not_found' }); + if (!scheduleId || !Number.isFinite(Number(scheduleId))) { + return res.status(400).json({ error: "invalid_schedule_id" }); } - const deleted = _mock.schedules.splice(index, 1)[0]; - return res.json({ success: true, schedule: deleted }); + + if (DEV_MOCK) { + if (!_mock.schedules) _mock.schedules = []; + const index = _mock.schedules.findIndex( + (s) => Number(s.id) === Number(scheduleId), + ); + if (index === -1) { + return res.status(404).json({ error: "schedule_not_found" }); + } + const deleted = _mock.schedules.splice(index, 1)[0]; + return res.json({ success: true, schedule: deleted }); + } + + const result = await db.query( + `DELETE FROM transit.schedules WHERE id = $1 RETURNING id`, + [scheduleId], + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: "schedule_not_found" }); + } + + res.json({ success: true, schedule: { id: Number(result.rows[0].id) } }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "internal_error", details: err.message }); } - - const result = await db.query( - `DELETE FROM transit.schedules WHERE id = $1 RETURNING id`, - [scheduleId] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ error: 'schedule_not_found' }); - } - - res.json({ success: true, schedule: { id: Number(result.rows[0].id) } }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + }, +); // ============================================ // PUBLIC - Get schedules (for public timetable) // ============================================ -app.get('/schedules', async (req, res) => { +app.get("/schedules", async (req, res) => { try { - const filters = parseScheduleFilters(req.query, { statuses: ['planned', 'active'] }); + const filters = parseScheduleFilters(req.query, { + statuses: ["planned", "active"], + }); if (DEV_MOCK) { const routeStopsByLine = new Map(); @@ -2667,23 +3789,25 @@ app.get('/schedules', async (req, res) => { }); } - 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)); + 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 res.json({ schedules }); } @@ -2733,11 +3857,18 @@ app.get('/schedules', async (req, res) => { 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 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(` + const routeStopsResult = await db.query( + ` SELECT rs.bus_line_id, rs.stop_id, @@ -2745,7 +3876,9 @@ app.get('/schedules', async (req, res) => { FROM transit.route_stops rs WHERE rs.bus_line_id = ANY($1::int[]) ORDER BY rs.bus_line_id ASC, rs.stop_order ASC - `, [lineIds]); + `, + [lineIds], + ); for (const row of routeStopsResult.rows) { const busLineId = Number(row.bus_line_id); @@ -2757,8 +3890,8 @@ app.get('/schedules', async (req, res) => { } const schedules = expandRecurringScheduleRows(result.rows) - .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)) - .map(row => ({ + .filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)) + .map((row) => ({ id: Number(row.id), busLineId: Number(row.bus_line_id), busId: Number(row.bus_id), @@ -2766,11 +3899,16 @@ app.get('/schedules', async (req, res) => { driverId: null, startTime: row.start_time, endTime: row.end_time, - weekdays: row.weekdays ? (typeof row.weekdays === 'string' ? JSON.parse(row.weekdays) : row.weekdays) : [], + 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, + estimatedDurationMinutes: + Number(row.estimated_duration_minutes) || null, isRecurringInstance: row.is_recurring_instance || false, templateId: row.template_id || null, })); @@ -2778,13 +3916,15 @@ app.get('/schedules', async (req, res) => { res.json({ schedules }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); -app.get('/schedules/overview', async (req, res) => { +app.get("/schedules/overview", async (req, res) => { try { - const filters = parseScheduleFilters(req.query, { statuses: ['planned', 'active'] }); + const filters = parseScheduleFilters(req.query, { + statuses: ["planned", "active"], + }); const overviewDateKey = filters.date || null; if (DEV_MOCK) { @@ -2801,14 +3941,20 @@ app.get('/schedules/overview', async (req, res) => { stop_id: routeStop.stopId, stop_order: routeStop.stopOrder, estimated_arrival_minutes: routeStop.estimatedArrivalMinutes, - stop_name: routeStop.name || '', + 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)); + 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, @@ -2821,21 +3967,28 @@ app.get('/schedules/overview', async (req, res) => { weekdays: schedule.weekdays || [], status: schedule.status, lineNumber: busLine?.lineNumber || 0, - busName: bus?.name || 'Unknown Bus', - licensePlate: bus?.licensePlate || '', + busName: bus?.name || "Unknown Bus", + licensePlate: bus?.licensePlate || "", createdAt: schedule.createdAt, }; }); - const schedules = mapRecurringOccurrenceRows(baseRows, overviewDateKey, routeStopsByLine) - .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); + const schedules = mapRecurringOccurrenceRows( + baseRows, + overviewDateKey, + routeStopsByLine, + ).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)); return res.json({ date: overviewDateKey, summary: { total: schedules.length, - recurring: schedules.filter(schedule => schedule.scheduleType === 'recurring').length, - oneTime: schedules.filter(schedule => schedule.scheduleType === 'one_time').length, + recurring: schedules.filter( + (schedule) => schedule.scheduleType === "recurring", + ).length, + oneTime: schedules.filter( + (schedule) => schedule.scheduleType === "one_time", + ).length, }, schedules, }); @@ -2862,34 +4015,40 @@ app.get('/schedules/overview', async (req, res) => { 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 + const result = + hasWeekdaysColumn.rows.length > 0 + ? await db.query( + `${baseSelect}, s.weekdays AS weekdays FROM transit.schedules s LEFT JOIN transit.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 + ORDER BY s.start_time ASC`, + [filters.statuses], + ) + : await db.query( + `${baseSelect}, NULL AS weekdays FROM transit.schedules s LEFT JOIN transit.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]); + ORDER BY s.start_time ASC`, + [filters.statuses], + ); const lineIds = Array.from( new Set( - result.rows - .map(row => Number(row.busLineId)) - .filter(Number.isFinite) - ) + result.rows.map((row) => Number(row.busLineId)).filter(Number.isFinite), + ), ); const routeStopsByLine = new Map(); if (lineIds.length > 0) { - const routeStopsResult = await db.query(` + const routeStopsResult = await db.query( + ` SELECT rs.bus_line_id, rs.stop_id, @@ -2900,7 +4059,9 @@ app.get('/schedules/overview', async (req, res) => { JOIN transit.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]); + `, + [lineIds], + ); for (const row of routeStopsResult.rows) { const busLineId = Number(row.bus_line_id); @@ -2914,25 +4075,41 @@ app.get('/schedules/overview', async (req, res) => { let schedules = []; if (filters.fromDate && filters.toDate) { - schedules = materializeScheduleRowsInRange(result.rows, filters.fromDate, filters.toDate, routeStopsByLine) - .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); + 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)); + schedules = mapRecurringOccurrenceRows( + result.rows, + overviewDateKey, + routeStopsByLine, + ).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)); } // Enrich schedules with live progress for the occurrence date(s) 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))]; + 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(` + const progressRes = await db.query( + ` SELECT schedule_id, scheduled_passed_date, stop_order, actual_passed_at, scheduled_passed_at, delay_minutes FROM transit.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]); + `, + [scheduleIds, occurrenceDates], + ); const progressByKey = new Map(); for (const row of progressRes.rows) { @@ -2941,20 +4118,33 @@ app.get('/schedules/overview', async (req, res) => { progressByKey.get(key).push(row); } - schedules = schedules.map(s => { + 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 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; + currentDelayMinutes = + Number(latest.delay_minutes ?? latest.delay_minutes) || null; } - const statusOverride = passedStops >= totalStops && totalStops > 0 ? 'completed' : (passedStops > 0 ? 'in_progress' : s.status); + const statusOverride = + passedStops >= totalStops && totalStops > 0 + ? "completed" + : passedStops > 0 + ? "in_progress" + : s.status; - return { ...s, status: statusOverride, passedStops, totalStops, currentDelayMinutes }; + return { + ...s, + status: statusOverride, + passedStops, + totalStops, + currentDelayMinutes, + }; }); } } else if (DEV_MOCK && schedules.length > 0) { @@ -2966,15 +4156,30 @@ app.get('/schedules/overview', async (req, res) => { progressByKey.get(key).push(p); } - schedules = schedules.map(s => { + 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 totalStops = (routeStopsByLine.get(Number(s.busLineId)) || []) + .length; const passedStops = progRows.length; - const latest = progRows.length > 0 ? progRows[progRows.length - 1] : null; - const currentDelayMinutes = latest ? Number(latest.delayMinutes) || 0 : null; - const statusOverride = passedStops >= totalStops && totalStops > 0 ? 'completed' : (passedStops > 0 ? 'in_progress' : s.status); - return { ...s, status: statusOverride, passedStops, totalStops, currentDelayMinutes }; + const latest = + progRows.length > 0 ? progRows[progRows.length - 1] : null; + const currentDelayMinutes = latest + ? Number(latest.delayMinutes) || 0 + : null; + const statusOverride = + passedStops >= totalStops && totalStops > 0 + ? "completed" + : passedStops > 0 + ? "in_progress" + : s.status; + return { + ...s, + status: statusOverride, + passedStops, + totalStops, + currentDelayMinutes, + }; }); } @@ -2982,21 +4187,25 @@ app.get('/schedules/overview', async (req, res) => { date: overviewDateKey, summary: { total: schedules.length, - recurring: schedules.filter(schedule => schedule.scheduleType === 'recurring').length, - oneTime: schedules.filter(schedule => schedule.scheduleType === 'one_time').length, + recurring: schedules.filter( + (schedule) => schedule.scheduleType === "recurring", + ).length, + oneTime: schedules.filter( + (schedule) => schedule.scheduleType === "one_time", + ).length, }, schedules, }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // PUBLIC - Get all recurring schedule templates (no date filtering) // ============================================ -app.get('/schedules/recurring', async (req, res) => { +app.get("/schedules/recurring", async (req, res) => { try { const filters = parseScheduleFilters(req.query); @@ -3014,16 +4223,25 @@ app.get('/schedules/recurring', async (req, res) => { stop_id: routeStop.stopId, stop_order: routeStop.stopOrder, estimated_arrival_minutes: routeStop.estimatedArrivalMinutes, - stop_name: routeStop.name || '', + 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)); + .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, @@ -3035,16 +4253,20 @@ app.get('/schedules/recurring', async (req, res) => { endTime: schedule.endTime, weekdays: schedule.weekdays || [], lineNumber: busLine?.lineNumber || 0, - busName: bus?.name || 'Unknown Bus', - licensePlate: bus?.licensePlate || '', + 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); + .filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)) + .map((row) => { + const materialized = materializeScheduleOverviewRow( + row, + null, + routeStopsByLine, + ); return { ...materialized, status: undefined, @@ -3084,11 +4306,16 @@ app.get('/schedules/recurring', async (req, res) => { WHERE s.weekdays IS NOT NULL `); - const lineIds = Array.from(new Set(result.rows.map(row => Number(row.busLineId)).filter(Number.isFinite))); + 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(` + const routeStopsResult = await db.query( + ` SELECT rs.bus_line_id, rs.stop_id, @@ -3099,7 +4326,9 @@ app.get('/schedules/recurring', async (req, res) => { JOIN transit.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]); + `, + [lineIds], + ); for (const row of routeStopsResult.rows) { const busLineId = Number(row.bus_line_id); @@ -3111,9 +4340,13 @@ app.get('/schedules/recurring', async (req, res) => { } const schedules = result.rows - .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)) - .map(row => { - const schedule = materializeScheduleOverviewRow(row, null, routeStopsByLine); + .filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)) + .map((row) => { + const schedule = materializeScheduleOverviewRow( + row, + null, + routeStopsByLine, + ); delete schedule.status; return schedule; }); @@ -3121,24 +4354,26 @@ app.get('/schedules/recurring', async (req, res) => { res.json({ schedules }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); -app.get('/schedules/:scheduleId', async (req, res) => { +app.get("/schedules/:scheduleId", async (req, res) => { try { const { scheduleId } = req.params; if (DEV_MOCK) { - const schedule = (_mock.schedules || []).find(s => Number(s.id) === Number(scheduleId)); + const schedule = (_mock.schedules || []).find( + (s) => Number(s.id) === Number(scheduleId), + ); if (!schedule) { - return res.status(404).json({ error: 'schedule_not_found' }); + return res.status(404).json({ error: "schedule_not_found" }); } - + // Enrich with bus line and fleet bus info - const busLine = _mock.busLines.find(bl => bl.id === schedule.busLineId); - const bus = _mock.fleetBuses.find(b => b.id === schedule.busId); - + const busLine = _mock.busLines.find((bl) => bl.id === schedule.busLineId); + const bus = _mock.fleetBuses.find((b) => b.id === schedule.busId); + return res.json({ schedule: { id: schedule.id, @@ -3149,12 +4384,13 @@ app.get('/schedules/:scheduleId', async (req, res) => { weekdays: schedule.weekdays || [], status: schedule.status, lineNumber: busLine?.lineNumber || 0, - busName: bus?.name || 'Unknown Bus', + busName: bus?.name || "Unknown Bus", }, }); } - const result = await db.query(` + const result = await db.query( + ` SELECT s.id, s.bus_line_id, @@ -3172,15 +4408,17 @@ app.get('/schedules/:scheduleId', async (req, res) => { LEFT JOIN transit.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]); + `, + [scheduleId], + ); if (result.rows.length === 0) { - return res.status(404).json({ error: 'schedule_not_found' }); + return res.status(404).json({ error: "schedule_not_found" }); } const row = result.rows[0]; const weekdays = parseWeekdaysValue(row.weekdays); - + res.json({ schedule: { id: Number(row.id), @@ -3199,33 +4437,37 @@ app.get('/schedules/:scheduleId', async (req, res) => { }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // PUBLIC - Get stops for a schedule's line // ============================================ -app.get('/schedules/:scheduleId/stops', async (req, res) => { +app.get("/schedules/:scheduleId/stops", async (req, res) => { try { const { scheduleId } = req.params; if (DEV_MOCK) { - const schedule = (_mock.schedules || []).find(s => Number(s.id) === Number(scheduleId)); + const schedule = (_mock.schedules || []).find( + (s) => Number(s.id) === Number(scheduleId), + ); if (!schedule) { - return res.status(404).json({ error: 'schedule_not_found' }); + return res.status(404).json({ error: "schedule_not_found" }); } const routeStops = (_mock.routeStops || []) - .filter(rs => Number(rs.busLineId) === Number(schedule.busLineId)) + .filter((rs) => Number(rs.busLineId) === Number(schedule.busLineId)) .sort((a, b) => a.stopOrder - b.stopOrder); - + // Map to match database response format - const formattedStops = routeStops.map(rs => { - const stop = (_mock.stops || []).find(s => Number(s.id) === Number(rs.stopId)); + 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', + name: stop?.name || rs.name || "Unknown Stop", latitude: stop?.latitude ?? rs.latitude ?? 0, longitude: stop?.longitude ?? rs.longitude ?? 0, estimatedArrivalMinutes: rs.estimatedArrivalMinutes || 0, @@ -3236,16 +4478,17 @@ app.get('/schedules/:scheduleId/stops', async (req, res) => { const scheduleResult = await db.query( `SELECT bus_line_id FROM transit.schedules WHERE id = $1`, - [scheduleId] + [scheduleId], ); if (scheduleResult.rows.length === 0) { - return res.status(404).json({ error: 'schedule_not_found' }); + return res.status(404).json({ error: "schedule_not_found" }); } const busLineId = scheduleResult.rows[0].bus_line_id; - const stopsResult = await db.query(` + const stopsResult = await db.query( + ` SELECT rs.id, rs.stop_order, @@ -3257,10 +4500,12 @@ app.get('/schedules/:scheduleId/stops', async (req, res) => { JOIN transit.stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = $1 ORDER BY rs.stop_order ASC - `, [busLineId]); + `, + [busLineId], + ); res.json({ - stops: stopsResult.rows.map(row => ({ + stops: stopsResult.rows.map((row) => ({ id: row.id, order: row.stop_order, name: row.name, @@ -3271,111 +4516,153 @@ app.get('/schedules/:scheduleId/stops', async (req, res) => { }); } catch (err) { console.error(err); - res.status(500).json({ error: 'internal_error', details: err.message }); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // DRIVER - Mark stop as passed (updates realtime state) // ============================================ -app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware, async (req, res) => { - try { - const scheduleId = Number(req.params.scheduleId); - const stopOrder = Number(req.params.stopOrder); +app.post( + "/driver/schedules/:scheduleId/stops/:stopOrder/passed", + authMiddleware, + async (req, res) => { + try { + const scheduleId = Number(req.params.scheduleId); + const stopOrder = Number(req.params.stopOrder); - if (!Number.isFinite(scheduleId) || !Number.isFinite(stopOrder) || stopOrder < 1) { - return res.status(400).json({ error: 'invalid_schedule_or_stop_order' }); - } - - const actualPassedAt = req.body?.actualPassedAt ? new Date(req.body.actualPassedAt) : new Date(); - if (Number.isNaN(actualPassedAt.getTime())) { - return res.status(400).json({ error: 'invalid_actual_passed_at' }); - } - - if (DEV_MOCK) { - const schedule = (_mock.schedules || []).find(s => Number(s.id) === scheduleId); - if (!schedule) { - return res.status(404).json({ error: 'schedule_not_found' }); + if ( + !Number.isFinite(scheduleId) || + !Number.isFinite(stopOrder) || + stopOrder < 1 + ) { + return res + .status(400) + .json({ error: "invalid_schedule_or_stop_order" }); } - const routeStop = (_mock.routeStops || []) - .find(rs => Number(rs.busLineId) === Number(schedule.busLineId) && Number(rs.stopOrder) === stopOrder); - - if (!routeStop) { - return res.status(404).json({ error: 'stop_not_found_for_schedule' }); + const actualPassedAt = req.body?.actualPassedAt + ? new Date(req.body.actualPassedAt) + : new Date(); + if (Number.isNaN(actualPassedAt.getTime())) { + return res.status(400).json({ error: "invalid_actual_passed_at" }); } - const stop = (_mock.stops || []).find(s => Number(s.id) === Number(routeStop.stopId)); - // Allow caller to specify which occurrence date this pertains to (for recurring templates) - 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)); - + if (DEV_MOCK) { + const schedule = (_mock.schedules || []).find( + (s) => Number(s.id) === scheduleId, + ); + if (!schedule) { + return res.status(404).json({ error: "schedule_not_found" }); + } - const existingIndex = (_mock.scheduleStopProgress || []).findIndex( - p => Number(p.scheduleId) === scheduleId && Number(p.stopOrder) === stopOrder - ); + const routeStop = (_mock.routeStops || []).find( + (rs) => + Number(rs.busLineId) === Number(schedule.busLineId) && + Number(rs.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 (!routeStop) { + return res.status(404).json({ error: "stop_not_found_for_schedule" }); + } - if (existingIndex >= 0) { - _mock.scheduleStopProgress[existingIndex] = progressRow; - } else { - _mock.scheduleStopProgress.push(progressRow); + const stop = (_mock.stops || []).find( + (s) => Number(s.id) === Number(routeStop.stopId), + ); + // Allow caller to specify which occurrence date this pertains to (for recurring templates) + 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 res.json({ + scheduleId, + stopOrder, + status: schedule.status, + delayMinutes, + passedStops: passedCount, + totalStops, + occurrenceDate: occurrenceDateKey, + }); } - 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; + await ensureScheduleStopProgressTable(); - if (passedCount >= totalStops) { - schedule.status = 'completed'; - schedule.endTime = actualPassedAt.toISOString(); - } else if (schedule.status === 'planned') { - schedule.status = 'in_progress'; - } - - return res.json({ - scheduleId, - stopOrder, - status: schedule.status, - delayMinutes, - passedStops: passedCount, - totalStops, - occurrenceDate: occurrenceDateKey, - }); - } - - await ensureScheduleStopProgressTable(); - - const scheduleResult = await db.query(` + const scheduleResult = await db.query( + ` SELECT s.id, s.bus_line_id, s.start_time, s.status FROM transit.schedules s WHERE s.id = $1 - `, [scheduleId]); + `, + [scheduleId], + ); - if (scheduleResult.rows.length === 0) { - return res.status(404).json({ error: 'schedule_not_found' }); - } + if (scheduleResult.rows.length === 0) { + return res.status(404).json({ error: "schedule_not_found" }); + } - const schedule = scheduleResult.rows[0]; + const schedule = scheduleResult.rows[0]; - const routeStopResult = await db.query(` + const routeStopResult = await db.query( + ` SELECT rs.stop_id, rs.stop_order, @@ -3386,82 +4673,125 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware WHERE rs.bus_line_id = $1 AND rs.stop_order = $2 LIMIT 1 - `, [schedule.bus_line_id, stopOrder]); + `, + [schedule.bus_line_id, stopOrder], + ); - if (routeStopResult.rows.length === 0) { - return res.status(404).json({ error: 'stop_not_found_for_schedule' }); - } - - const routeStop = routeStopResult.rows[0]; - // Determine occurrence date (for recurring templates callers can pass ?date=YYYY-MM-DD or body.date) - let occurrenceDateKey = req.query.date || req.body.date || toDateKey(schedule.start_time); - - // If this schedule is a recurring template, map to or create a concrete instance for the occurrence date - const weekdays = parseWeekdaysValue(schedule.weekdays); - let instanceScheduleId = scheduleId; - if (weekdays && weekdays.length > 0) { - // If no date provided but template occurs today, use today - if (!occurrenceDateKey) { - const todayKey = new Date().toISOString().slice(0, 10); - if (scheduleOccursOnDate(schedule, todayKey)) occurrenceDateKey = todayKey; + if (routeStopResult.rows.length === 0) { + return res.status(404).json({ error: "stop_not_found_for_schedule" }); } - if (occurrenceDateKey) { - const existingInstance = await db.query( - `SELECT id FROM transit.schedules WHERE template_id = $1 AND DATE(start_time) = $2 LIMIT 1`, - [scheduleId, occurrenceDateKey] - ); + const routeStop = routeStopResult.rows[0]; + // Determine occurrence date (for recurring templates callers can pass ?date=YYYY-MM-DD or body.date) + let occurrenceDateKey = + req.query.date || req.body.date || toDateKey(schedule.start_time); - if (existingInstance.rows.length > 0) { - instanceScheduleId = existingInstance.rows[0].id; - } else { - // Create instance from template - 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()); + // If this schedule is a recurring template, map to or create a concrete instance for the occurrence date + const weekdays = parseWeekdaysValue(schedule.weekdays); + let instanceScheduleId = scheduleId; + if (weekdays && weekdays.length > 0) { + // If no date provided but template occurs today, use today + if (!occurrenceDateKey) { + const todayKey = new Date().toISOString().slice(0, 10); + if (scheduleOccursOnDate(schedule, todayKey)) + occurrenceDateKey = todayKey; + } - 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); - } + if (occurrenceDateKey) { + const existingInstance = await db.query( + `SELECT id FROM transit.schedules WHERE template_id = $1 AND DATE(start_time) = $2 LIMIT 1`, + [scheduleId, occurrenceDateKey], + ); - const hasTemplateIdCol = await db.query(` + if (existingInstance.rows.length > 0) { + instanceScheduleId = existingInstance.rows[0].id; + } else { + // Create instance from template + 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(` + let insertRes; + if (hasTemplateIdCol.rows.length > 0) { + insertRes = await db.query( + ` WITH next_id AS (SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules) INSERT INTO transit.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(` + `, + [ + 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 transit.schedules) INSERT INTO transit.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]); - } + `, + [ + schedule.bus_line_id, + schedule.bus_id, + schedule.driver_id, + occurrenceStart.toISOString(), + occurrenceEnd ? occurrenceEnd.toISOString() : null, + ], + ); + } - instanceScheduleId = insertRes.rows[0].id; + 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)); + 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(` + await db.query( + ` INSERT INTO transit.schedule_stop_progress ( schedule_id, bus_line_id, @@ -3480,113 +4810,161 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware 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, - ]); + `, + [ + 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(` + const totalsResult = await db.query( + ` SELECT (SELECT COUNT(*)::int FROM transit.route_stops WHERE bus_line_id = $1) AS total_stops, (SELECT COUNT(*)::int FROM transit.schedule_stop_progress WHERE schedule_id = $2 AND scheduled_passed_date = $3) AS passed_stops - `, [schedule.bus_line_id, instanceScheduleId, occurrenceDateKey]); + `, + [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'; + 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(` + await db.query( + ` UPDATE transit.schedules SET status = $2, end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END WHERE id = $1 - `, [instanceScheduleId, nextStatus, actualPassedAt.toISOString()]); + `, + [instanceScheduleId, nextStatus, actualPassedAt.toISOString()], + ); - // Calculate delay live - const delayMinutes = Math.round((actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000); + // Calculate delay live + const delayMinutes = Math.round( + (actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000, + ); - res.json({ - scheduleId, - stopOrder, - status: nextStatus, - delayMinutes, - passedStops, - totalStops, - }); - } catch (err) { - console.error('POST /driver/schedules/:scheduleId/stops/:stopOrder/passed error:', err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + res.json({ + scheduleId, + stopOrder, + status: nextStatus, + delayMinutes, + passedStops, + totalStops, + }); + } catch (err) { + console.error( + "POST /driver/schedules/:scheduleId/stops/:stopOrder/passed error:", + err, + ); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); // ============================================ // PUBLIC - Live progress for passengers // ============================================ -app.get('/public/schedules/:scheduleId/progress', async (req, res) => { +app.get("/public/schedules/:scheduleId/progress", async (req, res) => { try { const scheduleId = Number(req.params.scheduleId); if (!Number.isFinite(scheduleId)) { - return res.status(400).json({ error: 'invalid_schedule_id' }); + return res.status(400).json({ error: "invalid_schedule_id" }); } if (DEV_MOCK) { - const schedule = (_mock.schedules || []).find(s => Number(s.id) === scheduleId); + const schedule = (_mock.schedules || []).find( + (s) => Number(s.id) === scheduleId, + ); if (!schedule) { - return res.status(404).json({ error: 'schedule_not_found' }); + return res.status(404).json({ error: "schedule_not_found" }); } - const busLine = (_mock.busLines || []).find(bl => Number(bl.id) === Number(schedule.busLineId)); + const busLine = (_mock.busLines || []).find( + (bl) => Number(bl.id) === Number(schedule.busLineId), + ); const routeStops = (_mock.routeStops || []) - .filter(rs => Number(rs.busLineId) === Number(schedule.busLineId)) + .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); + 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.filter( + (p) => p.scheduledPassedDate === mockProgressDate, + ); } - progressEvents = progressEvents.sort((a, b) => Number(a.stopOrder) - Number(b.stopOrder)); + 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 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 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)); + 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', + 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', + 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'); + 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 res.json({ schedule: { @@ -3605,7 +4983,10 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { nextStop: nextStop ? { ...nextStop, - predictedPassedAt: new Date(new Date(nextStop.scheduledPassedAt).getTime() + currentDelayMinutes * 60000).toISOString(), + predictedPassedAt: new Date( + new Date(nextStop.scheduledPassedAt).getTime() + + currentDelayMinutes * 60000, + ).toISOString(), } : null, stops, @@ -3615,7 +4996,8 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { await ensureScheduleStopProgressTable(); - const scheduleResult = await db.query(` + const scheduleResult = await db.query( + ` SELECT s.id, s.bus_line_id, @@ -3629,15 +5011,18 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { JOIN transit.bus_lines bl ON bl.id = s.bus_line_id WHERE s.id = $1 LIMIT 1 - `, [scheduleId]); + `, + [scheduleId], + ); if (scheduleResult.rows.length === 0) { - return res.status(404).json({ error: 'schedule_not_found' }); + return res.status(404).json({ error: "schedule_not_found" }); } const schedule = scheduleResult.rows[0]; - const routeStopsResult = await db.query(` + const routeStopsResult = await db.query( + ` SELECT rs.stop_id, rs.stop_order, @@ -3647,13 +5032,16 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { JOIN transit.stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = $1 ORDER BY rs.stop_order ASC - `, [schedule.bus_line_id]); + `, + [schedule.bus_line_id], + ); // Allow querying progress for a specific occurrence date (recurring templates) const progressDate = req.query.date || req.query.occurrenceDate || null; let progressResult; if (progressDate) { - progressResult = await db.query(` + progressResult = await db.query( + ` SELECT stop_order, actual_passed_at, @@ -3662,9 +5050,12 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { FROM transit.schedule_stop_progress WHERE schedule_id = $1 AND scheduled_passed_date = $2 ORDER BY stop_order ASC - `, [scheduleId, progressDate]); + `, + [scheduleId, progressDate], + ); } else { - progressResult = await db.query(` + progressResult = await db.query( + ` SELECT stop_order, actual_passed_at, @@ -3673,31 +5064,48 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { FROM transit.schedule_stop_progress WHERE schedule_id = $1 ORDER BY scheduled_passed_at ASC, stop_order ASC - `, [scheduleId]); + `, + [scheduleId], + ); } const progressByOrder = new Map( - progressResult.rows.map(row => [Number(row.stop_order), row]) + progressResult.rows.map((row) => [Number(row.stop_order), row]), ); - const latestProgress = progressResult.rows.length > 0 - ? progressResult.rows[progressResult.rows.length - 1] - : null; + const latestProgress = + progressResult.rows.length > 0 + ? progressResult.rows[progressResult.rows.length - 1] + : null; // Calculate delay live from latest passed stop let currentDelayMinutes = null; - if (latestProgress && latestProgress.actual_passed_at && latestProgress.scheduled_passed_at) { + 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); + currentDelayMinutes = Math.round( + (actualTime.getTime() - scheduledTime.getTime()) / 60000, + ); } - const occurrenceDateKeyForMapping = progressDate || toDateKey(schedule.start_time); + const occurrenceDateKeyForMapping = + progressDate || toDateKey(schedule.start_time); - const stops = routeStopsResult.rows.map(row => { + 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()); + 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)); @@ -3713,20 +5121,25 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { 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 Math.round( + (actualTime.getTime() - scheduledTime.getTime()) / 60000, + ); } return null; })(), - status: progress ? 'passed' : 'pending', + status: progress ? "passed" : "pending", }; }); - const passedStops = stops.filter(s => s.status === 'passed'); - const nextStop = stops.find(s => s.status === 'pending') || null; + 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'); + const driverState = + passedStops.length === 0 + ? "not_started" + : passedStops.length >= stops.length + ? "completed" + : "en_route"; res.json({ schedule: { @@ -3745,130 +5158,157 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { nextStop: nextStop ? { ...nextStop, - predictedPassedAt: new Date(new Date(nextStop.scheduledPassedAt).getTime() + currentDelayMinutes * 60000).toISOString(), + 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); - res.status(500).json({ error: 'internal_error', details: err.message }); + console.error("GET /public/schedules/:scheduleId/progress error:", err); + res.status(500).json({ error: "internal_error", details: err.message }); } }); // ============================================ // ADMIN - Check driver conflicts for generated schedules // ============================================ -app.post('/admin/check-driver-conflicts', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { generatedTimes, drivers, busLineId, busId, weekdays = [] } = req.body; +app.post( + "/admin/check-driver-conflicts", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { + generatedTimes, + drivers, + busLineId, + busId, + weekdays = [], + } = req.body; - if (!Array.isArray(generatedTimes) || !Array.isArray(drivers)) { - return res.status(400).json({ error: 'missing_required_fields' }); - } + if (!Array.isArray(generatedTimes) || !Array.isArray(drivers)) { + return res.status(400).json({ error: "missing_required_fields" }); + } - // Get all existing schedules - const schedResult = DEV_MOCK - ? { rows: _mock.schedules || [] } - : await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`); + // Get all existing schedules + const schedResult = DEV_MOCK + ? { rows: _mock.schedules || [] } + : await db.query( + `SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`, + ); - const existingSchedules = schedResult.rows.map(row => ({ - ...row, - weekdays: parseWeekdaysValue(row.weekdays || row.weekday_list || '[]'), - })); + const existingSchedules = schedResult.rows.map((row) => ({ + ...row, + weekdays: parseWeekdaysValue(row.weekdays || row.weekday_list || "[]"), + })); - // For each generated time, check conflicts with each driver - const assignments = generatedTimes.map(timeStr => { - const driverAssignments = drivers.map(driver => { - const hasConflict = checkDriverConflict( - driver.id, - timeStr, - timeStr, // Will be checked with start/end times of conflicts - weekdays, - existingSchedules - ); + // For each generated time, check conflicts with each driver + const assignments = generatedTimes.map((timeStr) => { + const driverAssignments = drivers.map((driver) => { + const hasConflict = checkDriverConflict( + driver.id, + timeStr, + timeStr, // Will be checked with start/end times of conflicts + weekdays, + existingSchedules, + ); + + return { + driverId: driver.id, + driverName: driver.name, + hasConflict, + assigned: false, + }; + }); return { - driverId: driver.id, - driverName: driver.name, - hasConflict, - assigned: false, + time: timeStr, + driverAssignments, + assignedDriverId: null, }; }); - return { - time: timeStr, - driverAssignments, - assignedDriverId: null, - }; - }); - - res.json({ assignments }); - } catch (err) { - console.error('/admin/check-driver-conflicts error:', err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + res.json({ assignments }); + } catch (err) { + console.error("/admin/check-driver-conflicts error:", err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); // ============================================ // ADMIN - Auto-assign schedules to drivers // ============================================ -app.post('/admin/assign-schedules', authMiddleware, adminMiddleware, async (req, res) => { - try { - const { assignments, busLineId, busId, weekdays = [] } = req.body; +app.post( + "/admin/assign-schedules", + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { assignments, busLineId, busId, weekdays = [] } = req.body; - if (!Array.isArray(assignments)) { - return res.status(400).json({ error: 'missing_assignments' }); - } - - // Group assignments by driver (round-robin or custom) - const schedulesToCreate = []; - let driverIndex = 0; - - for (const assignment of assignments) { - if (!assignment.time || !assignment.assignedDriverId) continue; - - const startTime = assignment.time; - const endTime = assignment.endTime; - - schedulesToCreate.push({ - busLineId, - busId, - driverId: assignment.assignedDriverId, - startTime, - endTime, - weekdays, - }); - } - - // Use bulk create endpoint logic - if (DEV_MOCK) { - if (!_mock.schedules) _mock.schedules = []; - const createdSchedules = []; - - for (const entry of schedulesToCreate) { - const schedule = { - id: (_mock.nextScheduleId || 1), - ...entry, - status: 'planned', - createdAt: new Date().toISOString(), - }; - _mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1); - _mock.schedules.push(schedule); - createdSchedules.push(schedule); + if (!Array.isArray(assignments)) { + return res.status(400).json({ error: "missing_assignments" }); } - return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules }); - } + // Group assignments by driver (round-robin or custom) + const schedulesToCreate = []; + let driverIndex = 0; - // For DB: use bulk insert - res.status(201).json({ createdCount: schedulesToCreate.length, message: 'would_insert_schedules' }); - } catch (err) { - console.error('/admin/assign-schedules error:', err); - res.status(500).json({ error: 'internal_error', details: err.message }); - } -}); + for (const assignment of assignments) { + if (!assignment.time || !assignment.assignedDriverId) continue; + + const startTime = assignment.time; + const endTime = assignment.endTime; + + schedulesToCreate.push({ + busLineId, + busId, + driverId: assignment.assignedDriverId, + startTime, + endTime, + weekdays, + }); + } + + // Use bulk create endpoint logic + if (DEV_MOCK) { + if (!_mock.schedules) _mock.schedules = []; + const createdSchedules = []; + + for (const entry of schedulesToCreate) { + const schedule = { + id: _mock.nextScheduleId || 1, + ...entry, + status: "planned", + createdAt: new Date().toISOString(), + }; + _mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1; + _mock.schedules.push(schedule); + createdSchedules.push(schedule); + } + + return res.status(201).json({ + createdCount: createdSchedules.length, + schedules: createdSchedules, + }); + } + + // For DB: use bulk insert + res.status(201).json({ + createdCount: schedulesToCreate.length, + message: "would_insert_schedules", + }); + } catch (err) { + console.error("/admin/assign-schedules error:", err); + res.status(500).json({ error: "internal_error", details: err.message }); + } + }, +); const PORT = process.env.PORT || 5001; @@ -3876,11 +5316,11 @@ const PORT = process.env.PORT || 5001; (async () => { try { await ensureScheduleStopProgressTable(); - console.log('✓ Database tables initialized'); + console.log("✓ Database tables initialized"); } catch (err) { - console.error('⚠ Error initializing tables:', err.message); + console.error("⚠ Error initializing tables:", err.message); } - + app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`)); })(); diff --git a/windows_xp_driver_ui/Makefile b/windows_xp_driver_ui/Makefile new file mode 100644 index 0000000..e106a7c --- /dev/null +++ b/windows_xp_driver_ui/Makefile @@ -0,0 +1,12 @@ +CC = i686-w64-mingw32-gcc +CFLAGS = -O2 -Wall -D_WIN32_WINNT=0x0501 -mwindows +LDFLAGS = -lwinhttp +TARGET = driver_xp.exe + +all: $(TARGET) + +$(TARGET): driver_xp.c + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + +clean: + rm -f $(TARGET) diff --git a/windows_xp_driver_ui/README.md b/windows_xp_driver_ui/README.md new file mode 100644 index 0000000..972e61e --- /dev/null +++ b/windows_xp_driver_ui/README.md @@ -0,0 +1,27 @@ +# Transit Driver XP + +Native Win32 driver client targeting Windows XP-era systems. + +The client authenticates with `auth_api` at `192.168.68.66:5000`, then requests +assigned weekly schedule templates from `transit_api` at +`192.168.68.66:5001/driver/schedules/daily`. + +Schedules are assigned through `transit.shifts`; the client does not expect a +`driver_id`, `bus_id`, or `weekdays` column on `transit.schedules`. + +## Build + +```bash +make clean && make +``` + +## Distribution + +Build the installer with: + +```bash +makensis installer.nsi +``` + +Distribute `TransitDriverXPSetup.exe`. It detects an existing installation, +stops the previous client, and replaces it in the same directory. diff --git a/windows_xp_driver_ui/TransitDriverXPSetup.exe b/windows_xp_driver_ui/TransitDriverXPSetup.exe new file mode 100644 index 0000000..d3b05d4 Binary files /dev/null and b/windows_xp_driver_ui/TransitDriverXPSetup.exe differ diff --git a/windows_xp_driver_ui/driver_xp.c b/windows_xp_driver_ui/driver_xp.c new file mode 100644 index 0000000..403605c --- /dev/null +++ b/windows_xp_driver_ui/driver_xp.c @@ -0,0 +1,1050 @@ +#define _WIN32_WINNT 0x0501 + +#include +#include +#include +#include +#include +#include + +#define ID_USERNAME 101 +#define ID_PASSWORD 102 +#define ID_LOGIN 103 +#define ID_REFRESH 104 +#define ID_OUTPUT 105 +#define ID_STATUS 106 +#define ID_DATE 107 +#define ID_ROUTES 108 +#define ID_STOPS 109 +#define ID_BACK 110 +#define ID_HALT 111 +#define ID_PAGE_TITLE 112 +#define ID_DATE_LABEL 113 +#define ID_USER_LABEL 114 +#define ID_PASSWORD_LABEL 115 +#define ID_ROUTES_LABEL 116 + +#define MAX_BUF 65535 +#define API_HOST "192.168.68.66" +#define AUTH_PORT 5000 +#define TRANSIT_PORT 5001 + +typedef struct { + HWND hwnd; + char token[512]; + char lastText[8192]; + int routeCount; + int routeLineIds[32]; + char routeLabels[32][256]; + int stopCount; + int activeStop; + char stopNames[64][128]; + char stopTimes[64][32]; +} APPSTATE; + +static void SetStatusText(HWND hwnd, const char *text) +{ + SetWindowTextA(GetDlgItem(hwnd, ID_STATUS), text); +} + +static void SetOutputText(HWND hwnd, const char *text) +{ + SetWindowTextA(GetDlgItem(hwnd, ID_OUTPUT), text); +} + +static void ShowControl(HWND hwnd, int id, int visible) +{ + ShowWindow(GetDlgItem(hwnd, id), visible ? SW_SHOW : SW_HIDE); +} + +static void ShowRouteScreen(HWND hwnd) +{ + SetWindowTextA(hwnd, "Transit Driver XP - Assigned Routes"); + SetWindowTextA(GetDlgItem(hwnd, ID_PAGE_TITLE), "TODAY'S ASSIGNED ROUTES"); + ShowControl(hwnd, ID_DATE, 1); + ShowControl(hwnd, ID_DATE_LABEL, 1); + ShowControl(hwnd, ID_ROUTES, 1); + ShowControl(hwnd, ID_ROUTES_LABEL, 1); + ShowControl(hwnd, ID_REFRESH, 1); + ShowControl(hwnd, ID_STOPS, 1); + ShowControl(hwnd, ID_OUTPUT, 1); + ShowControl(hwnd, ID_BACK, 0); + ShowControl(hwnd, ID_HALT, 0); + ShowControl(hwnd, ID_USERNAME, 0); + ShowControl(hwnd, ID_PASSWORD, 0); + ShowControl(hwnd, ID_USER_LABEL, 0); + ShowControl(hwnd, ID_PASSWORD_LABEL, 0); + ShowControl(hwnd, ID_LOGIN, 0); +} + +static void ShowStartScreen(HWND hwnd, APPSTATE *state) +{ + char title[256]; + SetWindowTextA(hwnd, "Transit Driver XP - Route in Progress"); + SetWindowTextA(GetDlgItem(hwnd, ID_PAGE_TITLE), "ROUTE IN PROGRESS"); + ShowControl(hwnd, ID_DATE, 0); + ShowControl(hwnd, ID_DATE_LABEL, 0); + ShowControl(hwnd, ID_ROUTES, 0); + ShowControl(hwnd, ID_ROUTES_LABEL, 0); + ShowControl(hwnd, ID_REFRESH, 0); + ShowControl(hwnd, ID_STOPS, 0); + ShowControl(hwnd, ID_OUTPUT, 0); + ShowControl(hwnd, ID_BACK, 1); + ShowControl(hwnd, ID_HALT, 1); + ShowControl(hwnd, ID_USERNAME, 0); + ShowControl(hwnd, ID_PASSWORD, 0); + ShowControl(hwnd, ID_USER_LABEL, 0); + ShowControl(hwnd, ID_PASSWORD_LABEL, 0); + ShowControl(hwnd, ID_LOGIN, 0); + snprintf(title, sizeof(title), "Route started\r\n\r\nTap the halt button when you arrive at the displayed halt."); + SetWindowTextA(GetDlgItem(hwnd, ID_STATUS), "Route in progress"); + SetWindowTextA(GetDlgItem(hwnd, ID_OUTPUT), title); + InvalidateRect(GetDlgItem(hwnd, ID_HALT), NULL, TRUE); + (void)state; +} + +static void UpdateHaltButton(HWND hwnd, APPSTATE *state) +{ + char label[256]; + + if (state->activeStop >= state->stopCount) { + SetWindowTextA(GetDlgItem(hwnd, ID_HALT), "ROUTE COMPLETE"); + SetStatusText(hwnd, "Route complete"); + return; + } + + snprintf(label, sizeof(label), "%s\r\n+%s min", state->stopNames[state->activeStop], state->stopTimes[state->activeStop]); + SetWindowTextA(GetDlgItem(hwnd, ID_HALT), label); + SetStatusText(hwnd, "Route in progress"); +} + +static void ShowLoginScreen(HWND hwnd) +{ + SetWindowTextA(hwnd, "Transit Driver XP - Driver Login"); + SetWindowTextA(GetDlgItem(hwnd, ID_PAGE_TITLE), "DRIVER LOGIN"); + ShowControl(hwnd, ID_DATE, 0); + ShowControl(hwnd, ID_DATE_LABEL, 0); + ShowControl(hwnd, ID_ROUTES, 0); + ShowControl(hwnd, ID_ROUTES_LABEL, 0); + ShowControl(hwnd, ID_REFRESH, 0); + ShowControl(hwnd, ID_STOPS, 0); + ShowControl(hwnd, ID_OUTPUT, 1); + ShowControl(hwnd, ID_BACK, 0); + ShowControl(hwnd, ID_HALT, 0); + ShowControl(hwnd, ID_USERNAME, 1); + ShowControl(hwnd, ID_PASSWORD, 1); + ShowControl(hwnd, ID_USER_LABEL, 1); + ShowControl(hwnd, ID_PASSWORD_LABEL, 1); + ShowControl(hwnd, ID_LOGIN, 1); +} + +static void DrawHaltButton(const DRAWITEMSTRUCT *draw) +{ + char label[256]; + HBRUSH brush = CreateSolidBrush(RGB(210, 55, 45)); + HPEN pen = CreatePen(PS_SOLID, 2, RGB(120, 25, 20)); + HGDIOBJ oldBrush = SelectObject(draw->hDC, brush); + HGDIOBJ oldPen = SelectObject(draw->hDC, pen); + RECT rect = draw->rcItem; + + Ellipse(draw->hDC, rect.left + 4, rect.top + 4, rect.right - 4, rect.bottom - 4); + SetBkMode(draw->hDC, TRANSPARENT); + SetTextColor(draw->hDC, RGB(255, 255, 255)); + GetWindowTextA(draw->hwndItem, label, sizeof(label)); + DrawTextA(draw->hDC, label, -1, &rect, DT_CENTER | DT_VCENTER | DT_WORDBREAK); + + SelectObject(draw->hDC, oldBrush); + SelectObject(draw->hDC, oldPen); + DeleteObject(brush); + DeleteObject(pen); +} + +static void PerformRefresh(HWND hwnd, APPSTATE *state); + +static void TrimNewlines(char *text) +{ + size_t len = strlen(text); + while (len > 0 && (text[len - 1] == '\n' || text[len - 1] == '\r' || text[len - 1] == ' ')) { + text[len - 1] = '\0'; + len--; + } +} + +static void ExtractJsonFieldString(const char *json, + const char *field, + char *out, + size_t outSize) +{ + char pattern[128]; + const char *key; + const char *colon; + const char *value; + const char *end; + + out[0] = '\0'; + if (!json || !field || outSize == 0) { + return; + } + + snprintf(pattern, sizeof(pattern), "\"%s\"", field); + key = strstr(json, pattern); + if (!key) { + return; + } + + colon = strchr(key + strlen(pattern), ':'); + if (!colon) { + return; + } + + value = colon + 1; + while (*value == ' ' || *value == '\t' || *value == '\n' || *value == '\r') { + value++; + } + + if (*value == '"') { + const char *q1 = value + 1; + const char *q2 = strchr(q1, '"'); + size_t len; + + if (!q2) { + return; + } + + len = (size_t)(q2 - q1); + if (len >= outSize) { + len = outSize - 1; + } + memcpy(out, q1, len); + out[len] = '\0'; + return; + } + + end = value; + while (*end && *end != ',' && *end != '}' && *end != '\n' && *end != '\r') { + end++; + } + + if (end == value) { + return; + } + + size_t len = (size_t)(end - value); + while (len > 0 && (value[len - 1] == ' ' || value[len - 1] == '\t' || value[len - 1] == '\r' || value[len - 1] == '\n')) { + len--; + } + + if (len >= outSize) { + len = outSize - 1; + } + + memcpy(out, value, len); + out[len] = '\0'; +} + +static char *FindObjectEnd(char *start) +{ + int depth = 0; + char *p = start; + + while (*p) { + if (*p == '{') { + depth++; + } else if (*p == '}') { + depth--; + if (depth == 0) { + return p; + } + } + p++; + } + + return NULL; +} + +static void ExtractScheduleSummary(const char *json, APPSTATE *state, char *out, size_t outSize) +{ + const char *routesKey; + const char *p; + char routeSummary[2048]; + char summary[8192]; + int routeCount = 0; + + out[0] = '\0'; + summary[0] = '\0'; + routeSummary[0] = '\0'; + state->routeCount = 0; + SendMessage(GetDlgItem(state->hwnd, ID_ROUTES), LB_RESETCONTENT, 0, 0); + + routesKey = strstr(json, "\"routes\""); + if (!routesKey) { + snprintf(out, outSize, "No routes found for this date."); + return; + } + + p = strchr(routesKey, '['); + if (!p) { + snprintf(out, outSize, "No routes found for this date."); + return; + } + + p++; + while (*p) { + char *objStart = strchr((char *)p, '{'); + char *objEnd; + char routeText[2048]; + char routeLine[128]; + char routeStatus[128]; + char routeStart[128]; + char routeEnd[128]; + char routeStartStop[128]; + char routeEndStop[128]; + char routeLineId[32]; + char routeStartTime[128]; + + if (!objStart) { + break; + } + + objEnd = FindObjectEnd(objStart); + if (!objEnd) { + break; + } + + if ((size_t)(objEnd - objStart + 1) >= sizeof(routeText)) { + break; + } + + memcpy(routeText, objStart, objEnd - objStart + 1); + routeText[objEnd - objStart + 1] = '\0'; + + routeLine[0] = '\0'; + routeStatus[0] = '\0'; + routeStart[0] = '\0'; + routeEnd[0] = '\0'; + routeStartStop[0] = '\0'; + routeEndStop[0] = '\0'; + routeLineId[0] = '\0'; + routeStartTime[0] = '\0'; + + ExtractJsonFieldString(routeText, "lineNumber", routeLine, sizeof(routeLine)); + ExtractJsonFieldString(routeText, "startTime", routeStartTime, sizeof(routeStartTime)); + ExtractJsonFieldString(routeText, "id", routeLineId, sizeof(routeLineId)); + { + char busLine[1024]; + const char *busKey = strstr(routeText, "\"busLine\""); + if (busKey) { + const char *busStart = strchr(busKey, '{'); + if (busStart) { + char *busEnd = FindObjectEnd((char *)busStart); + if (busEnd) { + size_t busLen = (size_t)(busEnd - busStart + 1); + if (busLen < sizeof(busLine)) { + memcpy(busLine, busStart, busLen); + busLine[busLen] = '\0'; + ExtractJsonFieldString(busLine, "lineNumber", routeLine, sizeof(routeLine)); + ExtractJsonFieldString(busLine, "id", routeLineId, sizeof(routeLineId)); + ExtractJsonFieldString(busLine, "startStop", routeStartStop, sizeof(routeStartStop)); + ExtractJsonFieldString(busLine, "endStop", routeEndStop, sizeof(routeEndStop)); + } + } + } + } + } + + ExtractJsonFieldString(routeText, "status", routeStatus, sizeof(routeStatus)); + ExtractJsonFieldString(routeText, "startTime", routeStart, sizeof(routeStart)); + ExtractJsonFieldString(routeText, "endTime", routeEnd, sizeof(routeEnd)); + if (routeStartStop[0] == '\0') { + ExtractJsonFieldString(routeText, "startStop", routeStartStop, sizeof(routeStartStop)); + } + if (routeEndStop[0] == '\0') { + ExtractJsonFieldString(routeText, "endStop", routeEndStop, sizeof(routeEndStop)); + } + + if (routeLine[0] == '\0') { + snprintf(routeLine, sizeof(routeLine), "R%d", routeCount + 1); + } + if (routeStatus[0] == '\0') { + snprintf(routeStatus, sizeof(routeStatus), "planned"); + } + if (routeStartStop[0] == '\0') { + snprintf(routeStartStop, sizeof(routeStartStop), "N/A"); + } + if (routeEndStop[0] == '\0') { + snprintf(routeEndStop, sizeof(routeEndStop), "N/A"); + } + + routeCount++; + if (state->routeCount < 32) { + state->routeLineIds[state->routeCount] = atoi(routeLineId); + snprintf(state->routeLabels[state->routeCount], sizeof(state->routeLabels[state->routeCount]), + "Line %s %s %s", routeLine, routeStart, routeStatus); + SendMessageA(GetDlgItem(state->hwnd, ID_ROUTES), LB_ADDSTRING, 0, + (LPARAM)state->routeLabels[state->routeCount]); + state->routeCount++; + } + snprintf(routeSummary + strlen(routeSummary), sizeof(routeSummary) - strlen(routeSummary), + "Route %s: %s -> %s | %s | %s\r\n", + routeLine, + routeStartStop, + routeEndStop, + routeStart, + routeStatus); + + p = objEnd + 1; + while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t' || *p == ',') { + p++; + } + + if (*p == ']' || *p == '\0') { + break; + } + } + + if (routeCount == 0) { + snprintf(out, outSize, "No routes found for this date."); + return; + } + + snprintf(summary, sizeof(summary), "Daily schedule (%d route%s)\r\n\r\n%s", + routeCount, routeCount == 1 ? "" : "s", + routeSummary); + + TrimNewlines(summary); + snprintf(out, outSize, "%s", summary); +} + +static void ExtractStopsSummary(const char *json, APPSTATE *state, char *out, size_t outSize) +{ + const char *stopsKey = strstr(json, "\"stops\""); + const char *p; + char summary[4096]; + int count = 0; + + summary[0] = '\0'; + if (!stopsKey || !(p = strchr(stopsKey, '['))) { + snprintf(out, outSize, "No stop details were returned for this route."); + return; + } + + p++; + while (*p && *p != ']') { + char *start = strchr((char *)p, '{'); + char *end; + char item[1024]; + char order[32]; + char name[128]; + char eta[32]; + + if (!start || !(end = FindObjectEnd(start))) { + break; + } + if ((size_t)(end - start + 1) >= sizeof(item)) { + break; + } + + memcpy(item, start, end - start + 1); + item[end - start + 1] = '\0'; + ExtractJsonFieldString(item, "order", order, sizeof(order)); + ExtractJsonFieldString(item, "name", name, sizeof(name)); + ExtractJsonFieldString(item, "estimatedArrivalMinutes", eta, sizeof(eta)); + if (name[0] == '\0') { + snprintf(name, sizeof(name), "Unnamed stop"); + } + if (eta[0] == '\0') { + snprintf(eta, sizeof(eta), "-"); + } + + snprintf(summary + strlen(summary), sizeof(summary) - strlen(summary), + "%s. %s (arrival +%s min)\r\n", order, name, eta); + if (count < 64) { + snprintf(state->stopNames[count], sizeof(state->stopNames[count]), "%s", name); + snprintf(state->stopTimes[count], sizeof(state->stopTimes[count]), "%s", eta); + } + count++; + p = end + 1; + while (*p == ' ' || *p == '\r' || *p == '\n' || *p == '\t' || *p == ',') { + p++; + } + } + + if (count == 0) { + snprintf(out, outSize, "No stop details were returned for this route."); + return; + } + + state->stopCount = count < 64 ? count : 64; + state->activeStop = 0; + snprintf(out, outSize, "Stop-by-stop timetable (%d stops)\r\n\r\n%s", count, summary); +} + +static void ExtractTokenFromJson(const char *json, char *outToken, size_t outSize) +{ + const char *tokenKey = strstr(json, "\"token\""); + const char *q1; + const char *q2; + + outToken[0] = '\0'; + if (!tokenKey) { + return; + } + + q1 = strchr(tokenKey, ':'); + if (!q1) { + return; + } + q1 = strchr(q1 + 1, '"'); + if (!q1) { + return; + } + q2 = strchr(q1 + 1, '"'); + if (!q2) { + return; + } + + q1++; + if ((size_t)(q2 - q1) >= outSize) { + strncpy(outToken, q1, outSize - 1); + outToken[outSize - 1] = '\0'; + return; + } + + memcpy(outToken, q1, q2 - q1); + outToken[q2 - q1] = '\0'; +} + +static void HttpErrorMessage(const char *context, DWORD error) +{ + (void)context; + (void)error; +} + +static int HttpRequestText(const char *method, + const char *url, + const char *body, + const char *authHeader, + char *response, + size_t responseSize) +{ + HINTERNET hSession = NULL; + HINTERNET hConnect = NULL; + HINTERNET hRequest = NULL; + wchar_t hostW[256]; + wchar_t pathW[512]; + wchar_t methodW[32]; + char host[256]; + char path[512]; + const char *src; + const char *slash; + const char *colon; + int port = 80; + DWORD flags = 0; + DWORD status = 0; + DWORD statusLen = sizeof(status); + DWORD bytesRead = 0; + DWORD totalRead = 0; + char buffer[MAX_BUF]; + int ok = 0; + + response[0] = '\0'; + + src = url; + if (strncmp(src, "http://", 7) == 0) { + src += 7; + } else if (strncmp(src, "https://", 8) == 0) { + src += 8; + } + + host[0] = '\0'; + path[0] = '/'; + path[1] = '\0'; + + slash = strchr(src, '/'); + colon = strchr(src, ':'); + + if (slash) { + size_t hostLen = (size_t)(slash - src); + if (hostLen >= sizeof(host)) { + hostLen = sizeof(host) - 1; + } + memcpy(host, src, hostLen); + host[hostLen] = '\0'; + snprintf(path, sizeof(path), "%.*s", (int)(strlen(slash)), slash); + } else if (colon) { + size_t hostLen = (size_t)(colon - src); + if (hostLen >= sizeof(host)) { + hostLen = sizeof(host) - 1; + } + memcpy(host, src, hostLen); + host[hostLen] = '\0'; + port = atoi(colon + 1); + snprintf(path, sizeof(path), "/"); + } else { + snprintf(host, sizeof(host), "%s", src); + snprintf(path, sizeof(path), "/"); + } + + if (colon && slash) { + size_t hostLen = (size_t)(colon - src); + if (hostLen >= sizeof(host)) { + hostLen = sizeof(host) - 1; + } + memcpy(host, src, hostLen); + host[hostLen] = '\0'; + port = atoi(colon + 1); + } + + if (host[0] == '\0') { + snprintf(host, sizeof(host), "192.168.68.66"); + } + + if (port == 0) { + port = 80; + } + + MultiByteToWideChar(CP_UTF8, 0, host, -1, hostW, 256); + MultiByteToWideChar(CP_UTF8, 0, path, -1, pathW, 512); + MultiByteToWideChar(CP_UTF8, 0, method, -1, methodW, 32); + + hSession = WinHttpOpen(L"TransitDriverXP/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) { + HttpErrorMessage("Unable to open WinHTTP session", GetLastError()); + return 0; + } + + hConnect = WinHttpConnect(hSession, hostW, port, 0); + if (!hConnect) { + char hostText[256]; + char errorText[512]; + WideCharToMultiByte(CP_UTF8, 0, hostW, -1, hostText, sizeof(hostText), NULL, NULL); + snprintf(errorText, sizeof(errorText), "Unable to connect to %s:%d", hostText, port); + HttpErrorMessage(errorText, GetLastError()); + goto cleanup; + } + + hRequest = WinHttpOpenRequest(hConnect, + methodW, + pathW, + NULL, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + 0); + if (!hRequest) { + HttpErrorMessage("Unable to open request", GetLastError()); + goto cleanup; + } + + if (body != NULL && body[0] != '\0') { + if (!WinHttpAddRequestHeaders(hRequest, + L"Content-Type: application/json\r\n", + -1, + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE)) { + HttpErrorMessage("Unable to add JSON content type", GetLastError()); + goto cleanup; + } + } + + if (authHeader && authHeader[0] != '\0') { + wchar_t wHeader[512]; + MultiByteToWideChar(CP_UTF8, 0, authHeader, -1, wHeader, 512); + if (!WinHttpAddRequestHeaders(hRequest, + wHeader, + -1, + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE)) { + HttpErrorMessage("Unable to add authorization header", GetLastError()); + goto cleanup; + } + } + + if (!WinHttpSendRequest(hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, + (LPVOID)body, + body ? (DWORD)strlen(body) : 0, + body ? (DWORD)strlen(body) : 0, + 0)) { + HttpErrorMessage("WinHTTP send request failed", GetLastError()); + goto cleanup; + } + + if (!WinHttpReceiveResponse(hRequest, NULL)) { + HttpErrorMessage("WinHTTP receive response failed", GetLastError()); + goto cleanup; + } + + if (!WinHttpQueryHeaders(hRequest, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, + &status, + &statusLen, + WINHTTP_NO_HEADER_INDEX)) { + HttpErrorMessage("Unable to query HTTP status", GetLastError()); + goto cleanup; + } + + if (status >= 400) { + snprintf(response, responseSize, "HTTP error %lu", status); + ok = 0; + goto cleanup; + } + + while (WinHttpQueryDataAvailable(hRequest, &bytesRead) && bytesRead > 0) { + DWORD actual = 0; + if (!WinHttpReadData(hRequest, buffer, sizeof(buffer) - 1, &actual)) { + HttpErrorMessage("Unable to read response data", GetLastError()); + goto cleanup; + } + if (actual == 0) { + break; + } + buffer[actual] = '\0'; + if (totalRead + actual + 1 < responseSize) { + memcpy(response + totalRead, buffer, actual); + totalRead += actual; + response[totalRead] = '\0'; + } + } + + ok = 1; + +cleanup: + if (hRequest) { + WinHttpCloseHandle(hRequest); + } + if (hConnect) { + WinHttpCloseHandle(hConnect); + } + if (hSession) { + WinHttpCloseHandle(hSession); + } + + return ok; +} + +static void PerformLogin(HWND hwnd, APPSTATE *state) +{ + char userCode[128]; + char password[128]; + char payload[512]; + char response[MAX_BUF]; + char token[256]; + + GetWindowTextA(GetDlgItem(hwnd, ID_USERNAME), userCode, sizeof(userCode)); + GetWindowTextA(GetDlgItem(hwnd, ID_PASSWORD), password, sizeof(password)); + + if (userCode[0] == '\0' || password[0] == '\0') { + SetStatusText(hwnd, "Enter user code and password."); + return; + } + + snprintf(payload, sizeof(payload), + "{\"userCode\":\"%s\",\"password\":\"%s\"}", + userCode, password); + + SetStatusText(hwnd, "Logging in..."); + if (!HttpRequestText("POST", "http://192.168.68.66:5000/login", payload, NULL, response, sizeof(response))) { + SetOutputText(hwnd, "Driver service unreachable. Please try again."); + SetStatusText(hwnd, "Login failed"); + return; + } + + ExtractTokenFromJson(response, token, sizeof(token)); + if (token[0] == '\0') { + SetOutputText(hwnd, "Driver service unreachable. Please try again."); + SetStatusText(hwnd, "Service unavailable"); + return; + } + + strncpy(state->token, token, sizeof(state->token) - 1); + state->token[sizeof(state->token) - 1] = '\0'; + ShowRouteScreen(hwnd); + SetStatusText(hwnd, "Loading today's assigned routes..."); + PerformRefresh(hwnd, state); +} + +static void PerformRefresh(HWND hwnd, APPSTATE *state) +{ + char response[MAX_BUF]; + char authHeader[512]; + char date[32]; + char scheduleUrl[256]; + + if (state->token[0] == '\0') { + SetStatusText(hwnd, "Login first."); + return; + } + + snprintf(authHeader, sizeof(authHeader), "Authorization: Bearer %s\r\n", state->token); + GetWindowTextA(GetDlgItem(hwnd, ID_DATE), date, sizeof(date)); + if (date[0] == '\0') { + snprintf(date, sizeof(date), "2026-09-17"); + } + snprintf(scheduleUrl, sizeof(scheduleUrl), "http://192.168.68.66:5001/driver/schedules/daily?date=%s", date); + SetStatusText(hwnd, "Loading schedule..."); + + if (!HttpRequestText("GET", scheduleUrl, NULL, authHeader, response, sizeof(response))) { + SetOutputText(hwnd, "Driver service unreachable. Please try again."); + SetStatusText(hwnd, "Schedule load failed"); + return; + } + + ExtractScheduleSummary(response, state, state->lastText, sizeof(state->lastText)); + SetOutputText(hwnd, state->lastText); + SetStatusText(hwnd, "Schedule loaded"); +} + +static void PerformLoadStops(HWND hwnd, APPSTATE *state) +{ + char response[MAX_BUF]; + char authHeader[512]; + char stopsUrl[256]; + LRESULT selected; + int lineId; + + if (state->token[0] == '\0') { + SetStatusText(hwnd, "Login first."); + return; + } + + selected = SendMessage(GetDlgItem(hwnd, ID_ROUTES), LB_GETCURSEL, 0, 0); + if (selected == LB_ERR || selected >= state->routeCount) { + SetStatusText(hwnd, "Select a route first."); + return; + } + + lineId = state->routeLineIds[selected]; + if (lineId <= 0) { + SetOutputText(hwnd, "This schedule does not contain a valid bus line ID."); + SetStatusText(hwnd, "Stop details unavailable"); + return; + } + + snprintf(authHeader, sizeof(authHeader), "Authorization: Bearer %s\r\n", state->token); + snprintf(stopsUrl, sizeof(stopsUrl), "http://192.168.68.66:5001/bus-lines/%d/stops", lineId); + SetStatusText(hwnd, "Loading stop details..."); + + if (!HttpRequestText("GET", stopsUrl, NULL, authHeader, response, sizeof(response))) { + SetOutputText(hwnd, "Driver service unreachable. Please try again."); + SetStatusText(hwnd, "Stop details failed"); + return; + } + + ExtractStopsSummary(response, state, state->lastText, sizeof(state->lastText)); + ShowStartScreen(hwnd, state); + UpdateHaltButton(hwnd, state); +} + +static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) +{ + static APPSTATE *state = NULL; + HWND hUser, hPassword, hLogin, hRefresh, hOutput, hStatus; + + switch (msg) { + case WM_CREATE: + { + LPCREATESTRUCT cs = (LPCREATESTRUCT)lParam; + SYSTEMTIME now; + char today[16]; + state = (APPSTATE *)calloc(1, sizeof(APPSTATE)); + state->hwnd = hwnd; + cs->lpCreateParams = state; + SetWindowLongPtr(hwnd, GWLP_USERDATA, (LONG_PTR)state); + GetLocalTime(&now); + snprintf(today, sizeof(today), "%04d-%02d-%02d", now.wYear, now.wMonth, now.wDay); + + CreateWindowExA(0, "STATIC", "DRIVER LOGIN", + WS_CHILD | WS_VISIBLE, + 20, 10, 330, 24, + hwnd, (HMENU)ID_PAGE_TITLE, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + CreateWindowExA(0, "STATIC", "Schedule date (YYYY-MM-DD)", + WS_CHILD | WS_VISIBLE, + 20, 38, 150, 20, + hwnd, (HMENU)ID_DATE_LABEL, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + CreateWindowExA(0, "EDIT", today, + WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL, + 175, 36, 130, 24, + hwnd, (HMENU)ID_DATE, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + CreateWindowExA(0, "STATIC", "User code", + WS_CHILD | WS_VISIBLE, + 20, 72, 90, 20, + hwnd, (HMENU)ID_USER_LABEL, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + hUser = CreateWindowExA(0, "EDIT", "", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL, + 120, 70, 180, 24, + hwnd, (HMENU)ID_USERNAME, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + SetFocus(hUser); + + CreateWindowExA(0, "STATIC", "Password", + WS_CHILD | WS_VISIBLE, + 20, 104, 90, 20, + hwnd, (HMENU)ID_PASSWORD_LABEL, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + hPassword = CreateWindowExA(0, "EDIT", "", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_PASSWORD | ES_AUTOHSCROLL, + 120, 102, 180, 24, + hwnd, (HMENU)ID_PASSWORD, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + hLogin = CreateWindowExA(0, "BUTTON", "Login", + WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 120, 138, 90, 30, + hwnd, (HMENU)ID_LOGIN, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + hRefresh = CreateWindowExA(0, "BUTTON", "Refresh schedule", + WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 220, 138, 130, 30, + hwnd, (HMENU)ID_REFRESH, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + hStatus = CreateWindowExA(0, "STATIC", "Idle", + WS_CHILD | WS_VISIBLE, + 20, 178, 500, 22, + hwnd, (HMENU)ID_STATUS, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + CreateWindowExA(0, "STATIC", "TODAY'S ROUTES", + WS_CHILD | WS_VISIBLE, + 20, 208, 200, 20, + hwnd, (HMENU)ID_ROUTES_LABEL, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + CreateWindowExA(WS_EX_CLIENTEDGE, "LISTBOX", "", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | LBS_NOTIFY | WS_VSCROLL, + 20, 230, 500, 105, + hwnd, (HMENU)ID_ROUTES, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + hOutput = CreateWindowExA(WS_EX_CLIENTEDGE, "EDIT", "", + WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_READONLY, + 20, 345, 500, 190, + hwnd, (HMENU)ID_OUTPUT, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + CreateWindowExA(0, "BUTTON", "Show selected route stops", + WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 20, 545, 190, 30, + hwnd, (HMENU)ID_STOPS, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + CreateWindowExA(0, "BUTTON", "HALT", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_OWNERDRAW, + 145, 120, 250, 250, + hwnd, (HMENU)ID_HALT, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + CreateWindowExA(0, "BUTTON", "Back to today's routes", + WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 20, 545, 190, 30, + hwnd, (HMENU)ID_BACK, ((LPCREATESTRUCT)lParam)->hInstance, NULL); + + SetWindowTextA(hOutput, "Driver console ready.\r\nLog in, choose a date, and refresh the schedule."); + ShowLoginScreen(hwnd); + return 0; + } + + case WM_COMMAND: + if (LOWORD(wParam) == ID_LOGIN && HIWORD(wParam) == BN_CLICKED) { + state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA); + PerformLogin(hwnd, state); + return 0; + } + + if (LOWORD(wParam) == ID_REFRESH && HIWORD(wParam) == BN_CLICKED) { + state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA); + PerformRefresh(hwnd, state); + return 0; + } + + if (LOWORD(wParam) == ID_STOPS && HIWORD(wParam) == BN_CLICKED) { + state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA); + PerformLoadStops(hwnd, state); + return 0; + } + + if (LOWORD(wParam) == ID_BACK && HIWORD(wParam) == BN_CLICKED) { + state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA); + ShowRouteScreen(hwnd); + SetStatusText(hwnd, "Today's routes"); + return 0; + } + + if (LOWORD(wParam) == ID_HALT && HIWORD(wParam) == BN_CLICKED) { + state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA); + if (state->activeStop < state->stopCount) { + state->activeStop++; + UpdateHaltButton(hwnd, state); + } + return 0; + } + + if (LOWORD(wParam) == ID_ROUTES && HIWORD(wParam) == LBN_SELCHANGE) { + state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA); + PerformLoadStops(hwnd, state); + return 0; + } + break; + + case WM_DRAWITEM: + if (wParam == ID_HALT) { + DrawHaltButton((DRAWITEMSTRUCT *)lParam); + return TRUE; + } + break; + + case WM_DESTROY: + PostQuitMessage(0); + return 0; + } + + return DefWindowProc(hwnd, msg, wParam, lParam); +} + +int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) +{ + WNDCLASSA wc; + HWND hwnd; + MSG msg; + + memset(&wc, 0, sizeof(wc)); + wc.lpfnWndProc = WndProc; + wc.hInstance = hInstance; + wc.hbrBackground = (HBRUSH)(COLOR_3DFACE + 1); + wc.lpszClassName = "TransitDriverXPClass"; + wc.hCursor = LoadCursor(NULL, IDC_ARROW); + wc.hIcon = LoadIcon(NULL, IDI_APPLICATION); + wc.style = CS_HREDRAW | CS_VREDRAW; + + if (!RegisterClassA(&wc)) { + MessageBoxA(NULL, "Failed to register window class.", "XP Driver App", MB_ICONERROR | MB_OK); + return 1; + } + + hwnd = CreateWindowExA(0, + "TransitDriverXPClass", + "Transit Driver XP", + WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, + CW_USEDEFAULT, + CW_USEDEFAULT, + 560, + 640, + NULL, + NULL, + hInstance, + NULL); + + if (!hwnd) { + MessageBoxA(NULL, "Failed to create window.", "XP Driver App", MB_ICONERROR | MB_OK); + return 1; + } + + ShowWindow(hwnd, nCmdShow); + UpdateWindow(hwnd); + + while (GetMessage(&msg, NULL, 0, 0)) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + + return (int)msg.wParam; +} diff --git a/windows_xp_driver_ui/driver_xp.exe b/windows_xp_driver_ui/driver_xp.exe new file mode 100755 index 0000000..f433101 Binary files /dev/null and b/windows_xp_driver_ui/driver_xp.exe differ diff --git a/windows_xp_driver_ui/installer.nsi b/windows_xp_driver_ui/installer.nsi new file mode 100644 index 0000000..3dac844 --- /dev/null +++ b/windows_xp_driver_ui/installer.nsi @@ -0,0 +1,57 @@ +!include "MUI2.nsh" + +!define APPNAME "Transit Driver XP" +!define COMPANYNAME "Transit Project" +!define VERSION "2.0.0" + +Name "${APPNAME}" +OutFile "TransitDriverXPSetup.exe" +InstallDir "$PROGRAMFILES\TransitDriverXP" +InstallDirRegKey HKCU "Software\TransitDriverXP" "InstallDir" +RequestExecutionLevel admin +VIProductVersion "2.0.0.0" +VIAddVersionKey "ProductName" "${APPNAME}" +VIAddVersionKey "CompanyName" "${COMPANYNAME}" +VIAddVersionKey "FileDescription" "Native Windows XP driver client" +VIAddVersionKey "FileVersion" "${VERSION}" +VIAddVersionKey "ProductVersion" "${VERSION}" +VIAddVersionKey "LegalCopyright" "Transit Project" + +Function .onInit + ReadRegStr $0 HKCU "Software\TransitDriverXP" "InstallDir" + StrCmp $0 "" done + IfFileExists "$0\driver_xp.exe" 0 done + StrCpy $INSTDIR $0 + MessageBox MB_OK|MB_ICONINFORMATION "An existing Transit Driver XP installation was found. It will be updated." + ExecWait '"$SYSDIR\taskkill.exe" /F /IM driver_xp.exe' +done: +FunctionEnd + +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +Section "Install" + SetOutPath "$INSTDIR" + SetOverwrite on + File "driver_xp.exe" + WriteRegStr HKCU "Software\TransitDriverXP" "InstallDir" "$INSTDIR" + WriteRegStr HKCU "Software\TransitDriverXP" "DisplayName" "${APPNAME}" + WriteRegStr HKCU "Software\TransitDriverXP" "Version" "${VERSION}" + CreateDirectory "$SMPROGRAMS\TransitDriverXP" + CreateShortCut "$SMPROGRAMS\TransitDriverXP\Transit Driver XP.lnk" "$INSTDIR\driver_xp.exe" + CreateShortCut "$DESKTOP\Transit Driver XP.lnk" "$INSTDIR\driver_xp.exe" + WriteUninstaller "$INSTDIR\Uninstall.exe" + CreateShortCut "$SMPROGRAMS\TransitDriverXP\Uninstall Transit Driver XP.lnk" "$INSTDIR\Uninstall.exe" +SectionEnd + +Section "Uninstall" + Delete "$DESKTOP\Transit Driver XP.lnk" + Delete "$SMPROGRAMS\TransitDriverXP\Transit Driver XP.lnk" + Delete "$SMPROGRAMS\TransitDriverXP\Uninstall Transit Driver XP.lnk" + RMDir "$SMPROGRAMS\TransitDriverXP" + Delete "$INSTDIR\driver_xp.exe" + Delete "$INSTDIR\Uninstall.exe" + RMDir "$INSTDIR" + DeleteRegKey HKCU "Software\TransitDriverXP" +SectionEnd