chore: standardize service domains and error logging
Build and Push Docker Images / detect-changes (push) Successful in 15s
Build and Push Docker Images / build-auth-api (push) Successful in 1m2s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m2s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m10s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m22s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s

This commit is contained in:
Sven laptop
2026-09-18 19:26:05 +02:00
parent 3f0a02aa04
commit 1154d1d658
21 changed files with 609 additions and 380 deletions
+17 -3
View File
@@ -3,12 +3,14 @@
## 🚀 5-Minute Setup ## 🚀 5-Minute Setup
### Step 1: Initial Setup ### Step 1: Initial Setup
```bash ```bash
# From the project root # From the project root
./scripts/init.sh ./scripts/init.sh
``` ```
This will: This will:
- Check Docker and Docker Compose installation - Check Docker and Docker Compose installation
- Create `.env` file from template - Create `.env` file from template
- Build all Docker images - Build all Docker images
@@ -17,6 +19,7 @@ This will:
### Step 2: Configure Database Connection ### Step 2: Configure Database Connection
Edit `.env` with your database details: Edit `.env` with your database details:
```env ```env
POSTGRES_HOST=your-db-host.com POSTGRES_HOST=your-db-host.com
POSTGRES_USER_AUTH=transitAUTH POSTGRES_USER_AUTH=transitAUTH
@@ -28,11 +31,13 @@ JWT_SECRET=your-secure-secret
``` ```
### Step 3: Start Services ### Step 3: Start Services
```bash ```bash
./scripts/deploy.sh start ./scripts/deploy.sh start
``` ```
### Step 4: Access Admin UI ### Step 4: Access Admin UI
``` ```
http://localhost:5174 http://localhost:5174
``` ```
@@ -42,28 +47,31 @@ Log in with your real user code and password.
## 📱 Admin UI Features ## 📱 Admin UI Features
### Stops Management ### Stops Management
- Create new transit stops - Create new transit stops
- Set geolocation (latitude, longitude) - Set geolocation (latitude, longitude)
- View all stops - View all stops
- Link stops to bus lines - Link stops to bus lines
### Bus Lines ### Bus Lines
- Create new bus routes - Create new bus routes
- Define start and end stops - Define start and end stops
- Set estimated travel time - Set estimated travel time
- Assign stops to lines with order - Assign stops to lines with order
### Fleet ### Fleet
- View fleet vehicles (read-only) - View fleet vehicles (read-only)
- See vehicle details - See vehicle details
## 🐳 Docker Services ## 🐳 Docker Services
| Port | Service | URL | | Port | Service | URL |
|------|---------|-----| | ---- | ------------ | ---------------------------------------- |
| 5174 | Admin UI | http://localhost:5174 | | 5174 | Admin UI | http://localhost:5174 |
| 5000 | Auth API | http://localhost:5000 | | 5000 | Auth API | https://auth.xtransit.testinstance.nl |
| 5001 | Transit API | http://localhost:5001 | | 5001 | Transit API | https://transit.xtransit.testinstance.nl |
| 4000 | Employee API | http://localhost:4000 | | 4000 | Employee API | http://localhost:4000 |
## 🔧 Common Commands ## 🔧 Common Commands
@@ -91,6 +99,7 @@ Log in with your real user code and password.
Use the real login flow with user code and password. Use the real login flow with user code and password.
### Production ### Production
- Keep `DEV_BYPASS=0` and `DEV_MOCK=0` - Keep `DEV_BYPASS=0` and `DEV_MOCK=0`
- Configure proper admin user codes in `ADMIN_USER_CODES` - Configure proper admin user codes in `ADMIN_USER_CODES`
- Ensure JWTs include the `userCode` claim - Ensure JWTs include the `userCode` claim
@@ -105,6 +114,7 @@ Use the real login flow with user code and password.
## ⚠️ Troubleshooting ## ⚠️ Troubleshooting
### "Address already in use" ### "Address already in use"
```bash ```bash
# Find and stop the process using the port # Find and stop the process using the port
lsof -i :5174 # Find process on port 5174 lsof -i :5174 # Find process on port 5174
@@ -112,6 +122,7 @@ kill -9 <PID> # Kill the process
``` ```
### "Cannot connect to database" ### "Cannot connect to database"
```bash ```bash
# Check database credentials in .env # Check database credentials in .env
# Verify database is accessible from your machine # Verify database is accessible from your machine
@@ -119,6 +130,7 @@ kill -9 <PID> # Kill the process
``` ```
### "Admin UI not loading" ### "Admin UI not loading"
```bash ```bash
# Check container is running # Check container is running
docker-compose ps admin_ui docker-compose ps admin_ui
@@ -131,7 +143,9 @@ docker-compose restart admin_ui
``` ```
### "Permission denied" errors ### "Permission denied" errors
The transit API user needs proper database permissions: The transit API user needs proper database permissions:
```sql ```sql
-- Grant permissions to transitAPI user -- Grant permissions to transitAPI user
GRANT CONNECT ON DATABASE "XTransit" TO transitAPI; GRANT CONNECT ON DATABASE "XTransit" TO transitAPI;
+7 -4
View File
@@ -6,6 +6,9 @@ Dit repository bevat een suite van Node.js microservices, een admin webapp en ee
- `employee_api` — Employee onboarding / management (standaard port 4000) - `employee_api` — Employee onboarding / management (standaard port 4000)
- `transit_api` — Transit/schedule endpoints (standaard port 5001) - `transit_api` — Transit/schedule endpoints (standaard port 5001)
- `error_log_api` — Centrale foutlogging en viewer (standaard port 5003) - `error_log_api` — Centrale foutlogging en viewer (standaard port 5003)
De error API is beschikbaar via `https://error.xtransit.testinstance.nl`.
- `admin_ui` — React + Vite beheerinterface - `admin_ui` — React + Vite beheerinterface
- `deprecated/` — legacy Flutter bus terminal, retained for reference while moving the client to a Windows XP-era build path - `deprecated/` — legacy Flutter bus terminal, retained for reference while moving the client to a Windows XP-era build path
@@ -88,7 +91,7 @@ POSTGRES_PASSWORD_ERROR_LOG=strong_password_error_log
JWT_SECRET=super_secret_jwt_key_here JWT_SECRET=super_secret_jwt_key_here
# Optioneel: centraal error log endpoint voor clients/services # Optioneel: centraal error log endpoint voor clients/services
ERROR_LOG_API_URL=http://error_log_api:5003/error_log ERROR_LOG_API_URL=https://error.xtransit.testinstance.nl/error_log
``` ```
Opmerking: de exacte variabelen worden gelezen door `docker-compose.yml`. Pas de namen/waarden aan naar jouw omgeving. Opmerking: de exacte variabelen worden gelezen door `docker-compose.yml`. Pas de namen/waarden aan naar jouw omgeving.
@@ -243,8 +246,8 @@ CREATE TABLE IF NOT EXISTS data.errors (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
"date" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "date" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"service" TEXT NOT NULL, "service" TEXT NOT NULL,
"partOfService" TEXT, partofservice TEXT,
"error" TEXT NOT NULL error TEXT NOT NULL
); );
``` ```
@@ -356,7 +359,7 @@ The Transit API exposes public (unauthenticated) schedule endpoints:
**Example:** **Example:**
```bash ```bash
curl http://localhost:5001/schedules?date=2026-05-06 curl https://transit.xtransit.testinstance.nl/schedules?date=2026-05-06
``` ```
**TimetableViewer Component:** **TimetableViewer Component:**
+6 -6
View File
@@ -46,9 +46,9 @@ npm install
Create a `.env` file in the `admin_ui` directory: Create a `.env` file in the `admin_ui` directory:
```env ```env
VITE_AUTH_API=http://localhost:5000 VITE_AUTH_API=https://auth.xtransit.testinstance.nl
VITE_TRANSIT_API=http://localhost:5001 VITE_TRANSIT_API=https://transit.xtransit.testinstance.nl
VITE_ERROR_LOG_API=http://localhost:5003 VITE_ERROR_LOG_API=https://error.xtransit.testinstance.nl
``` ```
### Run Development Server ### Run Development Server
@@ -103,9 +103,9 @@ http://localhost:5174
### Environment Variables ### Environment Variables
| Variable | Description | Default | | Variable | Description | Default |
| ------------------ | -------------------------------------------------------- | --------------------- | | ------------------ | --------------- | ---------------------------------------- |
| `VITE_AUTH_API` | Auth API URL (same-origin path recommended in Docker) | http://localhost:5000 | | `VITE_AUTH_API` | Auth API URL | https://auth.xtransit.testinstance.nl |
| `VITE_TRANSIT_API` | Transit API URL (same-origin path recommended in Docker) | http://localhost:5001 | | `VITE_TRANSIT_API` | Transit API URL | https://transit.xtransit.testinstance.nl |
### Runtime Configuration ### Runtime Configuration
+3 -3
View File
@@ -246,7 +246,7 @@ The schedule manager uses:
### Create a schedule (Admin) ### Create a schedule (Admin)
```bash ```bash
curl -X POST http://localhost:5001/admin/schedules \ curl -X POST https://transit.xtransit.testinstance.nl/admin/schedules \
-H "Authorization: Bearer YOUR_TOKEN" \ -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
@@ -261,13 +261,13 @@ curl -X POST http://localhost:5001/admin/schedules \
### Get today's schedules (Public) ### Get today's schedules (Public)
```bash ```bash
curl http://localhost:5001/schedules?date=$(date +%Y-%m-%d) curl https://transit.xtransit.testinstance.nl/schedules?date=$(date +%Y-%m-%d)
``` ```
### Get schedules for specific line (Public) ### Get schedules for specific line (Public)
```bash ```bash
curl http://localhost:5001/schedules?lineId=3 curl https://transit.xtransit.testinstance.nl/schedules?lineId=3
``` ```
## Future Enhancements ## Future Enhancements
+3 -3
View File
@@ -1,9 +1,9 @@
version: '3.8' version: "3.8"
services: services:
admin-ui: admin-ui:
build: . build: .
ports: ports:
- "5174:80" - "5174:80"
environment: environment:
VITE_AUTH_API: ${VITE_AUTH_API:-http://localhost:5000} VITE_AUTH_API: ${VITE_AUTH_API:-https://auth.xtransit.testinstance.nl}
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-http://localhost:5001} VITE_TRANSIT_API: ${VITE_TRANSIT_API:-https://transit.xtransit.testinstance.nl}
+3 -3
View File
@@ -3,9 +3,9 @@ set -e
cat > /usr/share/nginx/html/env-config.js <<EOF cat > /usr/share/nginx/html/env-config.js <<EOF
window.__ENV__ = { window.__ENV__ = {
AUTH_API: "${AUTH_API:-http://localhost:5000}", AUTH_API: "${AUTH_API:-https://auth.xtransit.testinstance.nl}",
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}", TRANSIT_API: "${TRANSIT_API:-https://transit.xtransit.testinstance.nl}",
ERROR_LOG_API: "${ERROR_LOG_API:-http://localhost:5003}" ERROR_LOG_API: "${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}"
} }
EOF EOF
+67 -48
View File
@@ -1,87 +1,106 @@
import axios from 'axios' import axios from "axios";
const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {} const RUNTIME =
typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};
const normalizeApiBaseUrl = (value, fallbackPath) => { const normalizeApiBaseUrl = (value, fallbackPath) => {
const rawValue = value || '' const rawValue = value || "";
if (!rawValue) return fallbackPath if (!rawValue) return fallbackPath;
if (/^https?:\/\//i.test(rawValue) || rawValue.startsWith('//') || rawValue.startsWith('/')) { if (
return rawValue /^https?:\/\//i.test(rawValue) ||
rawValue.startsWith("//") ||
rawValue.startsWith("/")
) {
return rawValue;
} }
return `${window.location.protocol}//${rawValue}` return `${window.location.protocol}//${rawValue}`;
} };
const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000') const AUTH_API = normalizeApiBaseUrl(
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001') RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API,
const ERROR_LOG_API = normalizeApiBaseUrl(RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, 'http://localhost:5003') "https://auth.xtransit.testinstance.nl",
);
const TRANSIT_API = normalizeApiBaseUrl(
RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API,
"https://transit.xtransit.testinstance.nl",
);
const ERROR_LOG_API = normalizeApiBaseUrl(
RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API,
"https://error.xtransit.testinstance.nl",
);
const reportClientError = async ({ service, partOfService, error }) => { const reportClientError = async ({ service, partofservice, error }) => {
try { try {
await fetch(`${ERROR_LOG_API}/error_log`, { await fetch(`${ERROR_LOG_API}/error_log`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
service, service,
partOfService, partofservice,
error: typeof error === 'string' ? error : JSON.stringify(error), error: typeof error === "string" ? error : JSON.stringify(error),
}), }),
}) });
} catch (e) { } catch (e) {
// ignore logging failures // ignore logging failures
} }
} };
export const authClient = axios.create({ baseURL: AUTH_API }) export const authClient = axios.create({ baseURL: AUTH_API });
authClient.interceptors.response.use( authClient.interceptors.response.use(
response => response, (response) => response,
error => { (error) => {
void reportClientError({ void reportClientError({
service: 'admin_ui', service: "admin_ui",
partOfService: 'authClient', partofservice: "authClient",
error: error?.response?.data?.error || error?.message || 'authClient_error', error:
}) error?.response?.data?.error || error?.message || "authClient_error",
return Promise.reject(error) });
} return Promise.reject(error);
) },
);
export const transitClient = (token) => { export const transitClient = (token) => {
const client = axios.create({ const client = axios.create({
baseURL: TRANSIT_API, baseURL: TRANSIT_API,
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}) });
// Auto-logout on unauthorized responses to avoid delayed failures // Auto-logout on unauthorized responses to avoid delayed failures
client.interceptors.response.use( client.interceptors.response.use(
response => response, (response) => response,
error => { (error) => {
try { try {
const status = error?.response?.status const status = error?.response?.status;
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
localStorage.removeItem('token') localStorage.removeItem("token");
localStorage.removeItem('user') localStorage.removeItem("user");
// reload to let App show the Login screen // reload to let App show the Login screen
if (typeof window !== 'undefined') window.location.reload() if (typeof window !== "undefined") window.location.reload();
} }
void reportClientError({ void reportClientError({
service: 'admin_ui', service: "admin_ui",
partOfService: 'transitClient', partofservice: "transitClient",
error: error?.response?.data?.error || error?.message || 'transitClient_error', error:
}) error?.response?.data?.error ||
error?.message ||
"transitClient_error",
});
} catch (e) { } catch (e) {
// ignore // ignore
} }
return Promise.reject(error) return Promise.reject(error);
} },
) );
return client return client;
} };
export const errorLogClient = (token) => axios.create({ export const errorLogClient = (token) =>
axios.create({
baseURL: ERROR_LOG_API, baseURL: ERROR_LOG_API,
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}) });
export { reportClientError } export { reportClientError };
+45 -31
View File
@@ -1,42 +1,44 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from "react";
import { errorLogClient } from '../api' import { errorLogClient } from "../api";
const formatDate = (value) => { const formatDate = (value) => {
if (!value) return '—' if (!value) return "—";
try { try {
return new Date(value).toLocaleString() return new Date(value).toLocaleString();
} catch (_) { } catch (_) {
return String(value) return String(value);
} }
} };
export default function ErrorLogViewer({ token }) { export default function ErrorLogViewer({ token }) {
const client = useMemo(() => errorLogClient(token), [token]) const client = useMemo(() => errorLogClient(token), [token]);
const [logs, setLogs] = useState([]) const [logs, setLogs] = useState([]);
const [service, setService] = useState('') const [service, setService] = useState("");
const [partOfService, setPartOfService] = useState('') const [partofservice, setPartofservice] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null);
const loadLogs = async () => { const loadLogs = async () => {
setLoading(true) setLoading(true);
setMessage(null) setMessage(null);
try { try {
const params = { limit: 100 } const params = { limit: 100 };
if (service) params.service = service if (service) params.service = service;
if (partOfService) params.partOfService = partOfService if (partofservice) params.partofservice = partofservice;
const res = await client.get('/error_log', { params }) const res = await client.get("/error_log", { params });
setLogs(res.data.errors || []) setLogs(res.data.errors || []);
} catch (err) { } catch (err) {
setMessage(err.response?.data?.error || err.message || 'Kon fouten niet laden') setMessage(
err.response?.data?.error || err.message || "Kon fouten niet laden",
);
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
useEffect(() => { useEffect(() => {
loadLogs() loadLogs();
}, [service, partOfService]) }, [service, partofservice]);
return ( return (
<section className="card"> <section className="card">
@@ -46,29 +48,41 @@ export default function ErrorLogViewer({ token }) {
<p>Laatste foutmeldingen uit de centrale logging service.</p> <p>Laatste foutmeldingen uit de centrale logging service.</p>
</div> </div>
<button className="secondary" onClick={loadLogs} disabled={loading}> <button className="secondary" onClick={loadLogs} disabled={loading}>
{loading ? 'Laden...' : 'Ververs'} {loading ? "Laden..." : "Ververs"}
</button> </button>
</div> </div>
<div className="form-row"> <div className="form-row">
<input placeholder="Service filter" value={service} onChange={e => setService(e.target.value)} /> <input
<input placeholder="Part of service filter" value={partOfService} onChange={e => setPartOfService(e.target.value)} /> placeholder="Service filter"
value={service}
onChange={(e) => setService(e.target.value)}
/>
<input
placeholder="Part of service filter"
value={partofservice}
onChange={(e) => setPartofservice(e.target.value)}
/>
</div> </div>
{message && <div className="message">{message}</div>} {message && <div className="message">{message}</div>}
<div className="list-grid"> <div className="list-grid">
{logs.map(log => ( {logs.map((log) => (
<article className="mini-card" key={log.id}> <article className="mini-card" key={log.id}>
<strong>{log.service}</strong> <strong>{log.service}</strong>
<div>{formatDate(log.date)}</div> <div>{formatDate(log.date)}</div>
<div className="muted" style={{ marginTop: 6 }}>{log.partOfService || '—'}</div> <div className="muted" style={{ marginTop: 6 }}>
{log.partofservice || "—"}
</div>
<div style={{ marginTop: 8 }}>{log.error}</div> <div style={{ marginTop: 8 }}>{log.error}</div>
</article> </article>
))} ))}
</div> </div>
{!logs.length && !message && <div className="muted">Nog geen foutmeldingen gevonden.</div>} {!logs.length && !message && (
<div className="muted">Nog geen foutmeldingen gevonden.</div>
)}
</section> </section>
) );
} }
+187 -84
View File
@@ -1,72 +1,85 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from "react";
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) { export default function TimetableViewer({
const [schedules, setSchedules] = useState([]) apiBaseUrl = "https://transit.xtransit.testinstance.nl",
const [summary, setSummary] = useState(null) }) {
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) const [schedules, setSchedules] = useState([]);
const [selectedLineId, setSelectedLineId] = useState('') const [summary, setSummary] = useState(null);
const [lines, setLines] = useState([]) const [selectedDate, setSelectedDate] = useState(
const [loading, setLoading] = useState(false) new Date().toISOString().split("T")[0],
const [error, setError] = useState(null) );
const [selectedLineId, setSelectedLineId] = useState("");
const [lines, setLines] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Fetch available bus lines // Fetch available bus lines
useEffect(() => { useEffect(() => {
const fetchLines = async () => { const fetchLines = async () => {
try { try {
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`) const res = await fetch(`${apiBaseUrl}/admin/bus-lines`);
const data = await res.json() const data = await res.json();
setLines(data.busLines || []) setLines(data.busLines || []);
} catch (err) { } catch (err) {
console.error('Failed to load lines:', err) console.error("Failed to load lines:", err);
} }
} };
fetchLines() fetchLines();
}, [apiBaseUrl]) }, [apiBaseUrl]);
// Fetch schedules based on date and line filter // Fetch schedules based on date and line filter
useEffect(() => { useEffect(() => {
const fetchSchedules = async () => { const fetchSchedules = async () => {
setLoading(true) setLoading(true);
setError(null) setError(null);
try { try {
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}` let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`;
if (selectedLineId) { if (selectedLineId) {
url += `&lineId=${selectedLineId}` url += `&lineId=${selectedLineId}`;
} }
const res = await fetch(url) const res = await fetch(url);
const data = await res.json() const data = await res.json();
setSchedules(data.schedules || []) setSchedules(data.schedules || []);
setSummary(data.summary || null) setSummary(data.summary || null);
} catch (err) { } catch (err) {
setError('Failed to load schedules: ' + err.message) setError("Failed to load schedules: " + err.message);
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
fetchSchedules() fetchSchedules();
}, [selectedDate, selectedLineId, apiBaseUrl]) }, [selectedDate, selectedLineId, apiBaseUrl]);
const timeSlots = generateTimeSlots() const timeSlots = generateTimeSlots();
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots) const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots);
return ( return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}> <div style={{ padding: "20px", maxWidth: "1200px", margin: "0 auto" }}>
<h1>Public Transit Timetable</h1> <h1>Public Transit Timetable</h1>
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}> <div style={{ marginBottom: "20px", display: "flex", gap: "10px" }}>
<input <input
type="date" type="date"
value={selectedDate} value={selectedDate}
onChange={e => setSelectedDate(e.target.value)} onChange={(e) => setSelectedDate(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }} style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #ccc",
}}
/> />
<select <select
value={selectedLineId} value={selectedLineId}
onChange={e => setSelectedLineId(e.target.value)} onChange={(e) => setSelectedLineId(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }} style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #ccc",
minWidth: "200px",
}}
> >
<option value="">All lines</option> <option value="">All lines</option>
{lines.map(line => ( {lines.map((line) => (
<option key={line.id} value={line.id}> <option key={line.id} value={line.id}>
Line {line.lineNumber} ({line.startStop} → {line.endStop}) Line {line.lineNumber} ({line.startStop} → {line.endStop})
</option> </option>
@@ -75,101 +88,191 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
</div> </div>
{summary && ( {summary && (
<div style={{ marginBottom: '16px', display: 'flex', gap: '12px', flexWrap: 'wrap' }}> <div
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#eef2ff', color: '#3730a3' }}> style={{
marginBottom: "16px",
display: "flex",
gap: "12px",
flexWrap: "wrap",
}}
>
<span
style={{
padding: "6px 10px",
borderRadius: "999px",
backgroundColor: "#eef2ff",
color: "#3730a3",
}}
>
Total: {summary.total} Total: {summary.total}
</span> </span>
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#ecfeff', color: '#155e75' }}> <span
style={{
padding: "6px 10px",
borderRadius: "999px",
backgroundColor: "#ecfeff",
color: "#155e75",
}}
>
Vast: {summary.recurring} Vast: {summary.recurring}
</span> </span>
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#f8fafc', color: '#334155' }}> <span
style={{
padding: "6px 10px",
borderRadius: "999px",
backgroundColor: "#f8fafc",
color: "#334155",
}}
>
Eenmalig: {summary.oneTime} Eenmalig: {summary.oneTime}
</span> </span>
</div> </div>
)} )}
{loading && <p>Loading schedules...</p>} {loading && <p>Loading schedules...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>} {error && <p style={{ color: "red" }}>{error}</p>}
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}> <table
<thead style={{ backgroundColor: '#f1f5f9' }}> style={{ width: "100%", borderCollapse: "collapse", marginTop: "20px" }}
>
<thead style={{ backgroundColor: "#f1f5f9" }}>
<tr> <tr>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th> <th
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th> style={{
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th> padding: "12px",
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Day status</th> textAlign: "left",
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Delay</th> borderBottom: "2px solid #ddd",
}}
>
Time
</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Line
</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Vehicle
</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Day status
</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Delay
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{timeSlots.map((slot, idx) => { {timeSlots.map((slot, idx) => {
const slotSchedules = schedulesBySlot[slot] || [] const slotSchedules = schedulesBySlot[slot] || [];
if (slotSchedules.length === 0) return null if (slotSchedules.length === 0) return null;
return slotSchedules.map((schedule, sidx) => ( return slotSchedules.map((schedule, sidx) => (
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}> <tr
<td style={{ padding: '12px' }}> key={`${idx}-${sidx}`}
{sidx === 0 ? slot : ''} style={{ borderBottom: "1px solid #eee" }}
</td> >
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}> <td style={{ padding: "12px" }}>{sidx === 0 ? slot : ""}</td>
<td
style={{
padding: "12px",
fontWeight: "bold",
color: "#4f46e5",
}}
>
Line {schedule.lineNumber} Line {schedule.lineNumber}
</td> </td>
<td style={{ padding: '12px' }}> <td style={{ padding: "12px" }}>
{schedule.busName} ({schedule.licensePlate}) {schedule.busName} ({schedule.licensePlate})
</td> </td>
<td style={{ padding: '12px' }}> <td style={{ padding: "12px" }}>
<span <span
style={{ style={{
padding: '4px 8px', padding: "4px 8px",
borderRadius: '4px', borderRadius: "4px",
backgroundColor: schedule.status === 'in_progress' ? '#dbeafe' : schedule.status === 'completed' ? '#dcfce7' : '#f3f4f6', backgroundColor:
color: schedule.status === 'in_progress' ? '#1d4ed8' : schedule.status === 'completed' ? '#166534' : '#374151', schedule.status === "in_progress"
fontSize: '0.9rem', ? "#dbeafe"
: schedule.status === "completed"
? "#dcfce7"
: "#f3f4f6",
color:
schedule.status === "in_progress"
? "#1d4ed8"
: schedule.status === "completed"
? "#166534"
: "#374151",
fontSize: "0.9rem",
}} }}
> >
{schedule.status} {schedule.status}
</span> </span>
</td> </td>
<td style={{ padding: '12px' }}> <td style={{ padding: "12px" }}>
{typeof schedule.currentDelayMinutes === 'number' && schedule.currentDelayMinutes !== null {typeof schedule.currentDelayMinutes === "number" &&
? `${schedule.currentDelayMinutes > 0 ? '+' : ''}${schedule.currentDelayMinutes} min` schedule.currentDelayMinutes !== null
: '—'} ? `${schedule.currentDelayMinutes > 0 ? "+" : ""}${schedule.currentDelayMinutes} min`
: "—"}
</td> </td>
</tr> </tr>
)) ));
})} })}
</tbody> </tbody>
</table> </table>
{schedules.length === 0 && !loading && ( {schedules.length === 0 && !loading && (
<p style={{ textAlign: 'center', color: '#999', marginTop: '40px' }}> <p style={{ textAlign: "center", color: "#999", marginTop: "40px" }}>
No schedules found for the selected date and line. No schedules found for the selected date and line.
</p> </p>
)} )}
</div> </div>
) );
} }
function generateTimeSlots() { function generateTimeSlots() {
const slots = [] const slots = [];
for (let hour = 0; hour < 24; hour++) { for (let hour = 0; hour < 24; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`) slots.push(
`${String(hour).padStart(2, "0")}:00-${String((hour + 1) % 24).padStart(2, "0")}:00`,
);
} }
return slots return slots;
} }
function groupSchedulesByTimeSlot(schedules, slots) { function groupSchedulesByTimeSlot(schedules, slots) {
const grouped = {} const grouped = {};
slots.forEach(slot => { slots.forEach((slot) => {
grouped[slot] = [] grouped[slot] = [];
}) });
schedules.forEach(schedule => { schedules.forEach((schedule) => {
const startHour = new Date(schedule.startTime).getHours() const startHour = new Date(schedule.startTime).getHours();
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00` const slot = `${String(startHour).padStart(2, "0")}:00-${String((startHour + 1) % 24).padStart(2, "0")}:00`;
if (grouped[slot]) { if (grouped[slot]) {
grouped[slot].push(schedule) grouped[slot].push(schedule);
} }
}) });
return grouped return grouped;
} }
@@ -215,9 +215,27 @@ export default function WeeklyScheduleEditor({
{message && <div className="message">{message}</div>} {message && <div className="message">{message}</div>}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: "1rem",
marginTop: "1.5rem",
alignItems: "start",
}}
>
{DAY_GROUPS.map((group) => ( {DAY_GROUPS.map((group) => (
<div key={group.value} style={{ marginTop: "1.5rem" }}> <section
<h4>{group.label}</h4> key={group.value}
style={{
minWidth: 0,
border: "1px solid #dbe3ef",
borderRadius: "8px",
background: "#f8fafc",
padding: "0.75rem",
}}
>
<h4 style={{ marginTop: 0 }}>{group.label}</h4>
{(groupedSchedules[group.value] || []).length === 0 ? ( {(groupedSchedules[group.value] || []).length === 0 ? (
<p>No schedules configured.</p> <p>No schedules configured.</p>
) : ( ) : (
@@ -235,12 +253,12 @@ export default function WeeklyScheduleEditor({
{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "} {dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "}
{schedule.endStop} {schedule.endStop}
</div> </div>
<div className="form-row" style={{ marginTop: "0.75rem" }}>
<select <select
value={schedule.driverId || ""} value={schedule.driverId || ""}
onChange={(event) => onChange={(event) =>
assignDriver(schedule.id, event.target.value) assignDriver(schedule.id, event.target.value)
} }
style={{ width: "100%", marginTop: "0.75rem" }}
> >
<option value="">Unassigned</option> <option value="">Unassigned</option>
{drivers.map((driver) => ( {drivers.map((driver) => (
@@ -253,16 +271,17 @@ export default function WeeklyScheduleEditor({
type="button" type="button"
className="danger" className="danger"
onClick={() => removeSchedule(schedule.id)} onClick={() => removeSchedule(schedule.id)}
style={{ width: "100%", marginTop: "0.5rem" }}
> >
Delete Delete
</button> </button>
</div>
</article> </article>
))} ))}
</div> </div>
)} )}
</div> </section>
))} ))}
</div>
</section> </section>
); );
} }
+58 -39
View File
@@ -1,41 +1,43 @@
const express = require('express'); const express = require("express");
const bcrypt = require('bcryptjs'); const bcrypt = require("bcryptjs");
const jwt = require('jsonwebtoken'); const jwt = require("jsonwebtoken");
const db = require('./db'); const db = require("./db");
require('dotenv').config(); require("dotenv").config();
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
// Simple CORS middleware for browser-based admin UI testing. // Simple CORS middleware for browser-based admin UI testing.
app.use((req, res, next) => { app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === 'OPTIONS') { if (req.method === "OPTIONS") {
return res.sendStatus(204); return res.sendStatus(204);
} }
next(); next();
}); });
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 ||
"https://error.xtransit.testinstance.nl/error_log";
function friendlyErrorMessage(statusCode) { function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.'; if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.'; if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
if (statusCode === 403) return 'Je hebt hier geen toegang toe.'; if (statusCode === 403) return "Je hebt hier geen toegang toe.";
if (statusCode === 404) return 'Niet gevonden.'; if (statusCode === 404) return "Niet gevonden.";
return 'Er ging iets mis. Probeer het later opnieuw.'; return "Er ging iets mis. Probeer het later opnieuw.";
} }
async function reportError({ partOfService, error }) { async function reportError({ partofservice, error }) {
try { try {
await fetch(ERROR_LOG_API_URL, { await fetch(ERROR_LOG_API_URL, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
service: 'auth_api', service: "auth_api",
partOfService, partofservice,
error, error,
}), }),
}); });
@@ -45,9 +47,13 @@ async function reportError({ partOfService, error }) {
app.use((req, res, next) => { app.use((req, res, next) => {
const originalJson = res.json.bind(res); const originalJson = res.json.bind(res);
res.json = (body) => { res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === 'object') { if (res.statusCode >= 400 && body && typeof body === "object") {
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`; const rawError =
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: 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({ error: friendlyErrorMessage(res.statusCode) });
} }
return originalJson(body); return originalJson(body);
@@ -58,46 +64,59 @@ app.use((req, res, next) => {
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
const ADMIN_USER_CODES = new Set( const ADMIN_USER_CODES = new Set(
String(process.env.ADMIN_USER_CODES || '') String(process.env.ADMIN_USER_CODES || "")
.split(',') .split(",")
.map(c => Number(c.trim())) .map((c) => Number(c.trim()))
.filter(Number.isFinite) .filter(Number.isFinite),
); );
app.post("/login", async (req, res) => {
app.post('/login', async (req, res) => {
try { try {
const { userCode, password } = req.body; const { userCode, password } = req.body;
if (!userCode || !password) return res.status(400).json({ error: 'userCode and password required' }); if (!userCode || !password)
return res.status(400).json({ error: "userCode and password required" });
const result = await db.query('SELECT id, usercode, password FROM workers.users WHERE usercode = $1', [userCode]); const result = await db.query(
"SELECT id, usercode, password FROM workers.users WHERE usercode = $1",
[userCode],
);
const user = result.rows[0]; const user = result.rows[0];
if (!user) return res.status(401).json({ error: 'invalid_credentials' }); if (!user) return res.status(401).json({ error: "invalid_credentials" });
const ok = await bcrypt.compare(password, user.password); const ok = await bcrypt.compare(password, user.password);
if (!ok) return res.status(401).json({ error: 'invalid_credentials' }); if (!ok) return res.status(401).json({ error: "invalid_credentials" });
const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode)); const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
const payload = { userId: user.id, userCode: user.usercode, isAdmin }; const payload = { userId: user.id, userCode: user.usercode, isAdmin };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "1h" });
res.json({ token, user: { id: user.id, userCode: user.usercode, job: user.job, isAdmin } }); res.json({
token,
user: { id: user.id, userCode: user.usercode, job: user.job, isAdmin },
});
} catch (err) { } catch (err) {
await reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) }); await reportError({
partofservice: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
console.error(err); console.error(err);
res.status(500).json({ error: friendlyErrorMessage(500) }); res.status(500).json({ error: friendlyErrorMessage(500) });
} }
}); });
app.get('/me', async (req, res) => { app.get("/me", async (req, res) => {
const auth = req.headers.authorization; const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'missing_token' }); if (!auth || !auth.startsWith("Bearer "))
return res.status(401).json({ error: "missing_token" });
const token = auth.slice(7); const token = auth.slice(7);
try { try {
const payload = jwt.verify(token, JWT_SECRET); const payload = jwt.verify(token, JWT_SECRET);
res.json({ user: payload }); res.json({ user: payload });
} catch (err) { } catch (err) {
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) }); void reportError({
partofservice: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
res.status(401).json({ error: friendlyErrorMessage(401) }); res.status(401).json({ error: friendlyErrorMessage(401) });
} }
}); });
+5 -5
View File
@@ -12,7 +12,7 @@ services:
PG_DB: ${POSTGRES_DB} PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_LOGGER} PG_USER: ${POSTGRES_USER_LOGGER}
PG_PASS: ${POSTGRES_PASSWORD_LOGGER} PG_PASS: ${POSTGRES_PASSWORD_LOGGER}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL} ERROR_LOG_API_URL: ${ERROR_LOG_API_URL:-https://error.xtransit.testinstance.nl/error_log}
restart: unless-stopped restart: unless-stopped
auth_api: auth_api:
@@ -30,7 +30,7 @@ services:
PG_PASS: ${POSTGRES_PASSWORD_AUTH} PG_PASS: ${POSTGRES_PASSWORD_AUTH}
ADMIN_USER_CODES: ${ADMIN_USER_CODES} ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_BYPASS: ${DEV_BYPASS:-0} DEV_BYPASS: ${DEV_BYPASS:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL} ERROR_LOG_API_URL: ${ERROR_LOG_API_URL:-https://error.xtransit.testinstance.nl/error_log}
depends_on: depends_on:
- error_log_api - error_log_api
restart: unless-stopped restart: unless-stopped
@@ -47,7 +47,7 @@ services:
PG_DB: ${POSTGRES_DB} PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_EMPLOYEE} PG_USER: ${POSTGRES_USER_EMPLOYEE}
PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE} PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL} ERROR_LOG_API_URL: ${ERROR_LOG_API_URL:-https://error.xtransit.testinstance.nl/error_log}
depends_on: depends_on:
- error_log_api - error_log_api
restart: unless-stopped restart: unless-stopped
@@ -66,7 +66,7 @@ services:
PG_USER: ${POSTGRES_USER_TRANSIT} PG_USER: ${POSTGRES_USER_TRANSIT}
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT} PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
DEV_MOCK: ${DEV_MOCK:-0} DEV_MOCK: ${DEV_MOCK:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL} ERROR_LOG_API_URL: ${ERROR_LOG_API_URL:-https://error.xtransit.testinstance.nl/error_log}
depends_on: depends_on:
- error_log_api - error_log_api
restart: unless-stopped restart: unless-stopped
@@ -79,7 +79,7 @@ services:
environment: environment:
AUTH_API: ${AUTH_API} AUTH_API: ${AUTH_API}
TRANSIT_API: ${TRANSIT_API} TRANSIT_API: ${TRANSIT_API}
ERROR_LOG_API: ${ERROR_LOG_API} ERROR_LOG_API: ${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}
depends_on: depends_on:
- auth_api - auth_api
- transit_api - transit_api
+58 -35
View File
@@ -1,29 +1,31 @@
const express = require('express'); const express = require("express");
const bcrypt = require('bcryptjs'); const bcrypt = require("bcryptjs");
const db = require('./db'); const db = require("./db");
require('dotenv').config(); require("dotenv").config();
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
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 ||
"https://error.xtransit.testinstance.nl/error_log";
function friendlyErrorMessage(statusCode) { function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.'; if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.'; if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
if (statusCode === 403) return 'Je hebt hier geen toegang toe.'; if (statusCode === 403) return "Je hebt hier geen toegang toe.";
if (statusCode === 404) return 'Niet gevonden.'; if (statusCode === 404) return "Niet gevonden.";
return 'Er ging iets mis. Probeer het later opnieuw.'; return "Er ging iets mis. Probeer het later opnieuw.";
} }
async function reportError({ partOfService, error }) { async function reportError({ partofservice, error }) {
try { try {
await fetch(ERROR_LOG_API_URL, { await fetch(ERROR_LOG_API_URL, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
service: 'employee_api', service: "employee_api",
partOfService, partofservice,
error, error,
}), }),
}); });
@@ -33,9 +35,13 @@ async function reportError({ partOfService, error }) {
app.use((req, res, next) => { app.use((req, res, next) => {
const originalJson = res.json.bind(res); const originalJson = res.json.bind(res);
res.json = (body) => { res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === 'object') { if (res.statusCode >= 400 && body && typeof body === "object") {
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`; const rawError =
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: 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({ error: friendlyErrorMessage(res.statusCode) });
} }
return originalJson(body); return originalJson(body);
@@ -45,35 +51,52 @@ app.use((req, res, next) => {
}); });
// Employee onboarding // Employee onboarding
app.post('/createemployee', async (req, res) => { app.post("/createemployee", async (req, res) => {
try { try {
const {firstname, lastname, job, password} = req.body; const { firstname, lastname, job, password } = req.body;
if (!firstname || !lastname || !job || !password) return res.status(400).json({error: 'Firstname, Lastname, Job title and password are required'}); if (!firstname || !lastname || !job || !password)
return res.status(400).json({
error: "Firstname, Lastname, Job title and password are required",
});
const resultCreateEmployee = await db.query('INSERT INTO workers.employees (firstname, lastname, job) VALUES ($1, $2, $3) RETURNING id', [firstname, lastname, job]); const resultCreateEmployee = await db.query(
"INSERT INTO workers.employees (firstname, lastname, job) VALUES ($1, $2, $3) RETURNING id",
[firstname, lastname, job],
);
const newEmployee = resultCreateEmployee.rows[0]; const newEmployee = resultCreateEmployee.rows[0];
const encryptedpassword = await bcrypt.hash(password, 12); const encryptedpassword = await bcrypt.hash(password, 12);
const resultCreateUser = await db.query('INSERT INTO workers.users (employee, password) VALUES ($1, $2) RETURNING usercode', [newEmployee.id, encryptedpassword]); const resultCreateUser = await db.query(
"INSERT INTO workers.users (employee, password) VALUES ($1, $2) RETURNING usercode",
[newEmployee.id, encryptedpassword],
);
const newUser = resultCreateUser.rows[0]; const newUser = resultCreateUser.rows[0];
return res.status(201).json({ employee: firstname, code: newUser.usercoden }); return res
} .status(201)
catch (err) { .json({ employee: firstname, code: newUser.usercoden });
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) }); } catch (err) {
void reportError({
partofservice: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
console.error(err); console.error(err);
res.status(500).json({ error: friendlyErrorMessage(500) }); res.status(500).json({ error: friendlyErrorMessage(500) });
} }
}) });
app.get('/health', async (_req, res, _next) => { app.get("/health", async (_req, res, _next) => {
try { try {
return res.status(200).json({endpoint: 'reachable'}) return res.status(200).json({ endpoint: "reachable" });
} } catch (err) {
catch (err) { void reportError({
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) }); partofservice: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
console.error(err); console.error(err);
res.status(500).json({ endpoint: 'unreachable', error: friendlyErrorMessage(500) }) res
.status(500)
.json({ endpoint: "unreachable", error: friendlyErrorMessage(500) });
} }
}) });
const PORT = process.env.PORT || 4000; const PORT = process.env.PORT || 4000;
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`)); app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
+51 -39
View File
@@ -1,29 +1,29 @@
const express = require('express'); const express = require("express");
const cors = require('cors'); const cors = require("cors");
const jwt = require('jsonwebtoken'); const jwt = require("jsonwebtoken");
require('dotenv').config(); require("dotenv").config();
const db = require('./db'); const db = require("./db");
const app = express(); const app = express();
app.use(express.json({ limit: '64kb' })); app.use(express.json({ limit: "64kb" }));
app.use(cors()); app.use(cors());
const PORT = Number(process.env.PORT || 5003); const PORT = Number(process.env.PORT || 5003);
const JWT_SECRET = process.env.JWT_SECRET || ''; const JWT_SECRET = process.env.JWT_SECRET || "";
// Admin verification is done by `auth_api` and carried in the JWT as `isAdmin`. // Admin verification is done by `auth_api` and carried in the JWT as `isAdmin`.
function toFriendlyError(statusCode) { function toFriendlyError(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.'; if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.'; if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
if (statusCode === 403) return 'Je hebt hier geen toegang toe.'; if (statusCode === 403) return "Je hebt hier geen toegang toe.";
if (statusCode === 404) return 'Niet gevonden.'; if (statusCode === 404) return "Niet gevonden.";
return 'Er ging iets mis. Probeer het later opnieuw.'; return "Er ging iets mis. Probeer het later opnieuw.";
} }
function normalizeErrorText(value) { function normalizeErrorText(value) {
if (!value) return 'Unknown error'; if (!value) return "Unknown error";
if (typeof value === 'string') return value; if (typeof value === "string") return value;
try { try {
return JSON.stringify(value); return JSON.stringify(value);
} catch (_) { } catch (_) {
@@ -31,20 +31,28 @@ function normalizeErrorText(value) {
} }
} }
async function insertErrorLog({ service, partOfService, error }) { async function insertErrorLog({
const serviceName = String(service || 'unknown_service').slice(0, 120); service,
const part = partOfService ? String(partOfService).slice(0, 240) : null; partofservice,
partOfService,
error,
}) {
const serviceName = String(service || "unknown_service").slice(0, 120);
const part =
partofservice || partOfService
? String(partofservice || partOfService).slice(0, 240)
: null;
const errorText = String(normalizeErrorText(error)).slice(0, 8000); const errorText = String(normalizeErrorText(error)).slice(0, 8000);
await db.query( await db.query(
'INSERT INTO data.errors ("service", "partOfService", "error") VALUES ($1, $2, $3)', "INSERT INTO data.errors (service, partofservice, error) VALUES ($1, $2, $3)",
[serviceName, part, errorText] [serviceName, part, errorText],
); );
} }
function verifyAdmin(req, res, next) { function verifyAdmin(req, res, next) {
const auth = req.headers.authorization; const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) { if (!auth || !auth.startsWith("Bearer ")) {
return res.status(401).json({ error: toFriendlyError(401) }); return res.status(401).json({ error: toFriendlyError(401) });
} }
@@ -61,33 +69,37 @@ function verifyAdmin(req, res, next) {
} }
} }
app.get('/health', async (_req, res) => { app.get("/health", async (_req, res) => {
res.json({ status: 'ok', service: 'error_log_api' }); res.json({ status: "ok", service: "error_log_api" });
}); });
app.post('/error_log', async (req, res) => { app.post("/error_log", async (req, res) => {
try { try {
const service = req.body?.service; const service = req.body?.service;
const partOfService = req.body?.partOfService || null; const partofservice =
req.body?.partofservice || req.body?.partOfService || null;
const error = req.body?.error; const error = req.body?.error;
if (!service || !error) { if (!service || !error) {
return res.status(400).json({ error: toFriendlyError(400) }); return res.status(400).json({ error: toFriendlyError(400) });
} }
await insertErrorLog({ service, partOfService, error }); await insertErrorLog({ service, partofservice, error });
return res.status(201).json({ ok: true }); return res.status(201).json({ ok: true });
} catch (err) { } catch (err) {
console.error('POST /error_log failed:', err); console.error("POST /error_log failed:", err);
return res.status(500).json({ error: toFriendlyError(500) }); return res.status(500).json({ error: toFriendlyError(500) });
} }
}); });
app.get('/error_log', verifyAdmin, async (req, res) => { app.get("/error_log", verifyAdmin, async (req, res) => {
try { try {
const limit = Math.min(Number(req.query.limit || 100), 500); const limit = Math.min(Number(req.query.limit || 100), 500);
const service = req.query.service ? String(req.query.service) : null; const service = req.query.service ? String(req.query.service) : null;
const partOfService = req.query.partOfService ? String(req.query.partOfService) : null; const partofservice =
req.query.partofservice || req.query.partOfService
? String(req.query.partofservice || req.query.partOfService)
: null;
const where = []; const where = [];
const params = []; const params = [];
@@ -96,25 +108,25 @@ app.get('/error_log', verifyAdmin, async (req, res) => {
params.push(service); params.push(service);
where.push(`"service" = $${params.length}`); where.push(`"service" = $${params.length}`);
} }
if (partOfService) { if (partofservice) {
params.push(partOfService); params.push(partofservice);
where.push(`"partOfService" ILIKE $${params.length}`); where.push(`partofservice ILIKE $${params.length}`);
params[params.length - 1] = `%${partOfService}%`; params[params.length - 1] = `%${partofservice}%`;
} }
params.push(limit); params.push(limit);
const rows = await db.query( const rows = await db.query(
`SELECT id, "date", "service", "partOfService", "error" `SELECT id, date, service, partofservice, error
FROM data.errors FROM data.errors
${where.length ? `WHERE ${where.join(' AND ')}` : ''} ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY "date" DESC, id DESC ORDER BY "date" DESC, id DESC
LIMIT $${params.length}`, LIMIT $${params.length}`,
params params,
); );
return res.json({ errors: rows.rows }); return res.json({ errors: rows.rows });
} catch (err) { } catch (err) {
console.error('GET /error_log failed:', err); console.error("GET /error_log failed:", err);
return res.status(500).json({ error: toFriendlyError(500) }); return res.status(500).json({ error: toFriendlyError(500) });
} }
}); });
@@ -122,12 +134,12 @@ app.get('/error_log', verifyAdmin, async (req, res) => {
app.use(async (err, req, res, _next) => { app.use(async (err, req, res, _next) => {
try { try {
await insertErrorLog({ await insertErrorLog({
service: 'error_log_api', service: "error_log_api",
partOfService: `${req.method} ${req.originalUrl}`, partofservice: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err), error: err?.stack || err?.message || String(err),
}); });
} catch (logErr) { } catch (logErr) {
console.error('Failed to log internal error:', logErr); console.error("Failed to log internal error:", logErr);
} }
if (res.headersSent) return; if (res.headersSent) return;
+2 -2
View File
@@ -14,8 +14,8 @@ case $COMMAND in
echo "" echo ""
echo "📋 Service URLs:" echo "📋 Service URLs:"
echo " Admin UI: http://localhost:5174" echo " Admin UI: http://localhost:5174"
echo " Auth API: http://localhost:5000" echo " Auth API: https://auth.xtransit.testinstance.nl"
echo " Transit API: http://localhost:5001" echo " Transit API: https://transit.xtransit.testinstance.nl"
echo " Employee API: http://localhost:4000" echo " Employee API: http://localhost:4000"
;; ;;
+2 -2
View File
@@ -53,7 +53,7 @@ Note: `ADMIN_USER_CODES` has been moved to the `auth_api` service. Configure adm
npm start npm start
``` ```
API draait op `http://localhost:5001` API draait op `https://transit.xtransit.testinstance.nl`
## API Endpoints ## API Endpoints
@@ -154,7 +154,7 @@ De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande client
Voorbeeld: Voorbeeld:
```bash ```bash
curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&busStopId=3" curl "https://transit.xtransit.testinstance.nl/schedules/overview?date=2026-05-06&chauffeurId=12&busStopId=3"
``` ```
### Navigation ### Navigation
+5 -4
View File
@@ -10,7 +10,8 @@ app.use(express.json());
app.use(cors()); app.use(cors());
const ERROR_LOG_API_URL = const ERROR_LOG_API_URL =
process.env.ERROR_LOG_API_URL || "http://localhost:5003/error_log"; process.env.ERROR_LOG_API_URL ||
"https://error.xtransit.testinstance.nl/error_log";
function friendlyErrorMessage(statusCode) { function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return "Controleer je invoer en probeer opnieuw."; if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
@@ -20,14 +21,14 @@ function friendlyErrorMessage(statusCode) {
return "Er ging iets mis. Probeer het later opnieuw."; return "Er ging iets mis. Probeer het later opnieuw.";
} }
async function reportError({ partOfService, error }) { async function reportError({ partofservice, error }) {
try { try {
await fetch(ERROR_LOG_API_URL, { await fetch(ERROR_LOG_API_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
service: "transit_api", service: "transit_api",
partOfService, partofservice,
error, error,
}), }),
}); });
@@ -41,7 +42,7 @@ app.use((req, res, next) => {
const rawError = const rawError =
body.error || body.message || body.details || `HTTP ${res.statusCode}`; body.error || body.message || body.details || `HTTP ${res.statusCode}`;
void reportError({ void reportError({
partOfService: `${req.method} ${req.originalUrl}`, partofservice: `${req.method} ${req.originalUrl}`,
error: rawError, error: rawError,
}); });
return originalJson({ error: friendlyErrorMessage(res.statusCode) }); return originalJson({ error: friendlyErrorMessage(res.statusCode) });
+3 -3
View File
@@ -2,9 +2,9 @@
Native Win32 driver client targeting Windows XP-era systems. Native Win32 driver client targeting Windows XP-era systems.
The client authenticates with `auth_api` at `192.168.68.66:5000`, then requests The client authenticates with `https://auth.xtransit.testinstance.nl`, then requests
assigned weekly schedule templates from `transit_api` at assigned weekly schedule templates from
`192.168.68.66:5001/driver/schedules/daily`. `https://transit.xtransit.testinstance.nl/driver/schedules/daily`.
Schedules are assigned through `transit.shifts`; the client does not expect a Schedules are assigned through `transit.shifts`; the client does not expect a
`driver_id`, `bus_id`, or `weekdays` column on `transit.schedules`. `driver_id`, `bus_id`, or `weekdays` column on `transit.schedules`.
Binary file not shown.
+8 -6
View File
@@ -25,7 +25,7 @@
#define ID_ROUTES_LABEL 116 #define ID_ROUTES_LABEL 116
#define MAX_BUF 65535 #define MAX_BUF 65535
#define API_HOST "192.168.68.66" #define API_HOST "auth.xtransit.testinstance.nl"
#define AUTH_PORT 5000 #define AUTH_PORT 5000
#define TRANSIT_PORT 5001 #define TRANSIT_PORT 5001
@@ -538,6 +538,7 @@ static int HttpRequestText(const char *method,
const char *src; const char *src;
const char *slash; const char *slash;
const char *colon; const char *colon;
int secure = 0;
int port = 80; int port = 80;
DWORD flags = 0; DWORD flags = 0;
DWORD status = 0; DWORD status = 0;
@@ -554,6 +555,7 @@ static int HttpRequestText(const char *method,
src += 7; src += 7;
} else if (strncmp(src, "https://", 8) == 0) { } else if (strncmp(src, "https://", 8) == 0) {
src += 8; src += 8;
secure = 1;
} }
host[0] = '\0'; host[0] = '\0';
@@ -596,7 +598,7 @@ static int HttpRequestText(const char *method,
} }
if (host[0] == '\0') { if (host[0] == '\0') {
snprintf(host, sizeof(host), "192.168.68.66"); snprintf(host, sizeof(host), "%s", API_HOST);
} }
if (port == 0) { if (port == 0) {
@@ -633,7 +635,7 @@ static int HttpRequestText(const char *method,
NULL, NULL,
WINHTTP_NO_REFERER, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_DEFAULT_ACCEPT_TYPES,
0); secure ? WINHTTP_FLAG_SECURE : 0);
if (!hRequest) { if (!hRequest) {
HttpErrorMessage("Unable to open request", GetLastError()); HttpErrorMessage("Unable to open request", GetLastError());
goto cleanup; goto cleanup;
@@ -747,7 +749,7 @@ static void PerformLogin(HWND hwnd, APPSTATE *state)
userCode, password); userCode, password);
SetStatusText(hwnd, "Logging in..."); SetStatusText(hwnd, "Logging in...");
if (!HttpRequestText("POST", "http://192.168.68.66:5000/login", payload, NULL, response, sizeof(response))) { if (!HttpRequestText("POST", "https://auth.xtransit.testinstance.nl/login", payload, NULL, response, sizeof(response))) {
SetOutputText(hwnd, "Driver service unreachable. Please try again."); SetOutputText(hwnd, "Driver service unreachable. Please try again.");
SetStatusText(hwnd, "Login failed"); SetStatusText(hwnd, "Login failed");
return; return;
@@ -784,7 +786,7 @@ static void PerformRefresh(HWND hwnd, APPSTATE *state)
if (date[0] == '\0') { if (date[0] == '\0') {
snprintf(date, sizeof(date), "2026-09-17"); snprintf(date, sizeof(date), "2026-09-17");
} }
snprintf(scheduleUrl, sizeof(scheduleUrl), "http://192.168.68.66:5001/driver/schedules/daily?date=%s", date); snprintf(scheduleUrl, sizeof(scheduleUrl), "https://transit.xtransit.testinstance.nl/driver/schedules/daily?date=%s", date);
SetStatusText(hwnd, "Loading schedule..."); SetStatusText(hwnd, "Loading schedule...");
if (!HttpRequestText("GET", scheduleUrl, NULL, authHeader, response, sizeof(response))) { if (!HttpRequestText("GET", scheduleUrl, NULL, authHeader, response, sizeof(response))) {
@@ -825,7 +827,7 @@ static void PerformLoadStops(HWND hwnd, APPSTATE *state)
} }
snprintf(authHeader, sizeof(authHeader), "Authorization: Bearer %s\r\n", state->token); 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); snprintf(stopsUrl, sizeof(stopsUrl), "https://transit.xtransit.testinstance.nl/bus-lines/%d/stops", lineId);
SetStatusText(hwnd, "Loading stop details..."); SetStatusText(hwnd, "Loading stop details...");
if (!HttpRequestText("GET", stopsUrl, NULL, authHeader, response, sizeof(response))) { if (!HttpRequestText("GET", stopsUrl, NULL, authHeader, response, sizeof(response))) {
Binary file not shown.