feat: add centralized error logging system

- Implemented Error Log API to capture and store error logs from various services.
- Updated admin_ui to include Error Log Viewer component for inspecting logs.
- Enhanced error handling in auth_api, employee_api, and transit_api to report errors to the Error Log API.
- Modified existing services to utilize the new error logging functionality.
- Updated Docker Compose configuration to include the Error Log API service.
- Added error logging service in bus_terminal for Flutter application.
This commit is contained in:
Sven laptop
2026-05-17 23:58:23 +02:00
parent 73de0d713e
commit 0e19aa0521
31 changed files with 688 additions and 34 deletions
+43
View File
@@ -9,6 +9,7 @@ on:
- 'auth_api/**' - 'auth_api/**'
- 'employee_api/**' - 'employee_api/**'
- 'transit_api/**' - 'transit_api/**'
- 'error_log_api/**'
- 'admin_ui/**' - 'admin_ui/**'
- '.github/workflows/docker-build.yml' - '.github/workflows/docker-build.yml'
pull_request: pull_request:
@@ -153,6 +154,48 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
build-error-log-api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/error_log_api
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./error_log_api
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-admin-ui: build-admin-ui:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
+1
View File
@@ -1,6 +1,7 @@
.env .env
/*.env /*.env
transit_api/.env transit_api/.env
error_log_api/.env
# Local build / node_modules # Local build / node_modules
admin_ui/node_modules/ admin_ui/node_modules/
+30
View File
@@ -5,6 +5,7 @@ Dit repository bevat een suite van drie Node.js microservices (APIs) en bijbehor
- `auth_api` — Authenticatie en token-uitgifte (standaard port 5000) - `auth_api` — Authenticatie en token-uitgifte (standaard port 5000)
- `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)
Er is ook een GitHub Actions workflow aanwezig die Docker images bouwt en pusht naar GitHub Container Registry (GHCR). Er is ook een GitHub Actions workflow aanwezig die Docker images bouwt en pusht naar GitHub Container Registry (GHCR).
@@ -77,9 +78,14 @@ POSTGRES_USER_EMPLOYEE=employee_user
POSTGRES_PASSWORD_EMPLOYEE=strong_password_employee POSTGRES_PASSWORD_EMPLOYEE=strong_password_employee
POSTGRES_USER_TRANSIT=transit_user POSTGRES_USER_TRANSIT=transit_user
POSTGRES_PASSWORD_TRANSIT=strong_password_transit POSTGRES_PASSWORD_TRANSIT=strong_password_transit
POSTGRES_USER_ERROR_LOG=error_logger
POSTGRES_PASSWORD_ERROR_LOG=strong_password_error_log
# JWT secret voor services die JWT gebruiken # JWT secret voor services die JWT gebruiken
JWT_SECRET=super_secret_jwt_key_here JWT_SECRET=super_secret_jwt_key_here
# Optioneel: centraal error log endpoint voor clients/services
ERROR_LOG_API_URL=http://error_log_api:5003/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.
@@ -218,6 +224,30 @@ Database schema en migraties zitten niet in deze repository. Zorg dat je:
- de juiste schema's (`workers`, `routes`, etc.) aanmaakt - de juiste schema's (`workers`, `routes`, etc.) aanmaakt
- gebruikers en rechten configureert voor de verschillende services - gebruikers en rechten configureert voor de verschillende services
### Error logging schema
Voor de centrale foutlogging maak je schema `data` en tabel `data.errors` aan:
```sql
CREATE SCHEMA IF NOT EXISTS data;
CREATE TABLE IF NOT EXISTS data.errors (
id BIGSERIAL PRIMARY KEY,
"date" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"service" TEXT NOT NULL,
"partOfService" TEXT,
"error" TEXT NOT NULL
);
```
Gebruik een aparte gebruiker met minimale rechten, bijvoorbeeld `error_logger`:
```sql
CREATE ROLE error_logger LOGIN PASSWORD 'strong_password_error_log';
GRANT USAGE ON SCHEMA data TO error_logger;
GRANT INSERT, SELECT ON data.errors TO error_logger;
```
Als je nog geen database hebt, kun je lokaal een Postgres container gebruiken (development): Als je nog geen database hebt, kun je lokaal een Postgres container gebruiken (development):
```bash ```bash
+5
View File
@@ -10,6 +10,7 @@ Modern React + Vite web application for comprehensive transit system administrat
✅ **Stop Linking** - Assign stops to bus lines with order and ETA ✅ **Stop Linking** - Assign stops to bus lines with order and ETA
✅ **JWT Authentication** - Secure authentication with role-based access ✅ **JWT Authentication** - Secure authentication with role-based access
✅ **Production Ready** - Docker containerized with runtime env configuration ✅ **Production Ready** - Docker containerized with runtime env configuration
✅ **Error Log Viewer** - Inspect centralized error logs from all services
## Local Development ## Local Development
@@ -31,6 +32,7 @@ Create a `.env` file in the `admin_ui` directory:
```env ```env
VITE_AUTH_API=http://localhost:5000 VITE_AUTH_API=http://localhost:5000
VITE_TRANSIT_API=http://localhost:5001 VITE_TRANSIT_API=http://localhost:5001
VITE_ERROR_LOG_API=http://localhost:5003
``` ```
### Run Development Server ### Run Development Server
@@ -109,12 +111,14 @@ When running in Docker, environment variables are injected at runtime via `docke
- `BusLinesManager.jsx` - Create/list bus routes - `BusLinesManager.jsx` - Create/list bus routes
- `FleetManager.jsx` - View fleet vehicles - `FleetManager.jsx` - View fleet vehicles
- `LinkStop.jsx` - Link stops to bus lines - `LinkStop.jsx` - Link stops to bus lines
- `ErrorLogViewer.jsx` - Central error log viewer
### API Integration ### API Integration
The app communicates with: The app communicates with:
- **Auth API** (/login, /me) - **Auth API** (/login, /me)
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses) - **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
- **Error Log API** (/error_log)
All requests include JWT Bearer token for authentication. All requests include JWT Bearer token for authentication.
@@ -139,6 +143,7 @@ Users must have a `userCode` in their JWT that matches `ADMIN_USER_CODES` enviro
3. Configure proper JWT generation in auth_api 3. Configure proper JWT generation in auth_api
4. Set strong `JWT_SECRET` environment variable 4. Set strong `JWT_SECRET` environment variable
5. Configure `ADMIN_USER_CODES` with actual admin user IDs 5. Configure `ADMIN_USER_CODES` with actual admin user IDs
6. Configure `VITE_ERROR_LOG_API` if you use a custom logging endpoint
## Troubleshooting ## Troubleshooting
+2 -1
View File
@@ -4,7 +4,8 @@ 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:-http://localhost:5000}",
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}" TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}",
ERROR_LOG_API: "${ERROR_LOG_API:-http://localhost:5003}"
} }
EOF EOF
+2
View File
@@ -5,6 +5,7 @@ import LinkStop from './components/LinkStop'
import BusLinesManager from './components/BusLinesManager' import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager' import FleetManager from './components/FleetManager'
import ScheduleMaker from './components/ScheduleMaker' import ScheduleMaker from './components/ScheduleMaker'
import ErrorLogViewer from './components/ErrorLogViewer'
export default function App() { export default function App() {
const [token, setToken] = useState(localStorage.getItem('token')) const [token, setToken] = useState(localStorage.getItem('token'))
@@ -46,6 +47,7 @@ export default function App() {
<FleetManager token={token} user={user} /> <FleetManager token={token} user={user} />
<LinkStop token={token} user={user} /> <LinkStop token={token} user={user} />
<ScheduleMaker token={token} user={user} /> <ScheduleMaker token={token} user={user} />
<ErrorLogViewer token={token} />
</main> </main>
</div> </div>
) )
+41
View File
@@ -14,9 +14,38 @@ const normalizeApiBaseUrl = (value, fallbackPath) => {
const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000') const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000')
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001') const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
const ERROR_LOG_API = normalizeApiBaseUrl(RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, 'http://localhost:5003')
const reportClientError = async ({ service, partOfService, error }) => {
try {
await fetch(`${ERROR_LOG_API}/error_log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service,
partOfService,
error: typeof error === 'string' ? error : JSON.stringify(error),
}),
})
} catch (e) {
// ignore logging failures
}
}
export const authClient = axios.create({ baseURL: AUTH_API }) export const authClient = axios.create({ baseURL: AUTH_API })
authClient.interceptors.response.use(
response => response,
error => {
void reportClientError({
service: 'admin_ui',
partOfService: 'authClient',
error: error?.response?.data?.error || error?.message || 'authClient_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,
@@ -35,6 +64,11 @@ export const transitClient = (token) => {
// 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({
service: 'admin_ui',
partOfService: 'transitClient',
error: error?.response?.data?.error || error?.message || 'transitClient_error',
})
} catch (e) { } catch (e) {
// ignore // ignore
} }
@@ -44,3 +78,10 @@ export const transitClient = (token) => {
return client return client
} }
export const errorLogClient = (token) => axios.create({
baseURL: ERROR_LOG_API,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
export { reportClientError }
+1 -1
View File
@@ -11,7 +11,7 @@ export default function BusLinesManager({ token, user }) {
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('') const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingLine, setEditingLine] = useState(null) const [editingLine, setEditingLine] = useState(null)
@@ -0,0 +1,74 @@
import React, { useEffect, useMemo, useState } from 'react'
import { errorLogClient } from '../api'
const formatDate = (value) => {
if (!value) return '—'
try {
return new Date(value).toLocaleString()
} catch (_) {
return String(value)
}
}
export default function ErrorLogViewer({ token }) {
const client = useMemo(() => errorLogClient(token), [token])
const [logs, setLogs] = useState([])
const [service, setService] = useState('')
const [partOfService, setPartOfService] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState(null)
const loadLogs = async () => {
setLoading(true)
setMessage(null)
try {
const params = { limit: 100 }
if (service) params.service = service
if (partOfService) params.partOfService = partOfService
const res = await client.get('/error_log', { params })
setLogs(res.data.errors || [])
} catch (err) {
setMessage(err.response?.data?.error || err.message || 'Kon fouten niet laden')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadLogs()
}, [service, partOfService])
return (
<section className="card">
<div className="section-header">
<div>
<h2>Error log</h2>
<p>Laatste foutmeldingen uit de centrale logging service.</p>
</div>
<button className="secondary" onClick={loadLogs} disabled={loading}>
{loading ? 'Laden...' : 'Ververs'}
</button>
</div>
<div className="form-row">
<input 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>
{message && <div className="message">{message}</div>}
<div className="list-grid">
{logs.map(log => (
<article className="mini-card" key={log.id}>
<strong>{log.service}</strong>
<div>{formatDate(log.date)}</div>
<div className="muted" style={{ marginTop: 6 }}>{log.partOfService || '—'}</div>
<div style={{ marginTop: 8 }}>{log.error}</div>
</article>
))}
</div>
{!logs.length && !message && <div className="muted">Nog geen foutmeldingen gevonden.</div>}
</section>
)
}
@@ -172,7 +172,7 @@ const FixedScheduleEditor = ({
const [availableDrivers, setAvailableDrivers] = useState([]) const [availableDrivers, setAvailableDrivers] = useState([])
const [pendingBatchData, setPendingBatchData] = useState(null) const [pendingBatchData, setPendingBatchData] = useState(null)
const isAdmin = Boolean(user?.userCode) const isAdmin = Boolean(user?.isAdmin)
const resetForm = () => { const resetForm = () => {
setEditingScheduleId(null) setEditingScheduleId(null)
+1 -1
View File
@@ -9,7 +9,7 @@ export default function FleetManager({ token, user }) {
const [licensePlate, setLicensePlate] = useState('') const [licensePlate, setLicensePlate] = useState('')
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [canCreateBus, setCanCreateBus] = useState(true) const [canCreateBus, setCanCreateBus] = useState(true)
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.isAdmin))
const [editingBus, setEditingBus] = useState(null) const [editingBus, setEditingBus] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null)
+1 -1
View File
@@ -13,7 +13,7 @@ export default function LinkStop({ token, user }) {
const [draggedItem, setDraggedItem] = useState(null) const [draggedItem, setDraggedItem] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null)
const isAdmin = Boolean(user?.userCode) const isAdmin = Boolean(user?.isAdmin)
const nextOrder = useMemo(() => stops.length + 1, [stops]) const nextOrder = useMemo(() => stops.length + 1, [stops])
+1 -1
View File
@@ -11,7 +11,7 @@ export default function ScheduleMaker({ token, user }) {
const [drivers, setDrivers] = useState([]) const [drivers, setDrivers] = useState([])
const [selectedDriverFilter, setSelectedDriverFilter] = useState('') const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.isAdmin))
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0]) const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
const loadScheduleTemplates = async () => { const loadScheduleTemplates = async () => {
+1 -1
View File
@@ -8,7 +8,7 @@ export default function StopsManager({ token, user }) {
const [lat, setLat] = useState('') const [lat, setLat] = useState('')
const [lon, setLon] = useState('') const [lon, setLon] = useState('')
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingStop, setEditingStop] = useState(null) const [editingStop, setEditingStop] = useState(null)
+51 -4
View File
@@ -18,7 +18,51 @@ app.use((req, res, next) => {
next(); next();
}); });
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.';
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: 'auth_api',
partOfService,
error,
}),
});
} catch (_) {}
}
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 });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
const ADMIN_USER_CODES = new Set(
String(process.env.ADMIN_USER_CODES || '')
.split(',')
.map(c => Number(c.trim()))
.filter(Number.isFinite)
);
app.post('/login', async (req, res) => { app.post('/login', async (req, res) => {
@@ -33,13 +77,15 @@ app.post('/login', async (req, res) => {
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 payload = { userId: user.id, userCode: user.usercode }; const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
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 } }); 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) });
console.error(err); console.error(err);
res.status(500).json({ error: 'internal_error' }); res.status(500).json({ error: friendlyErrorMessage(500) });
} }
}); });
@@ -51,7 +97,8 @@ app.get('/me', async (req, res) => {
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) {
res.status(401).json({ error: 'invalid_token' }); void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
res.status(401).json({ error: friendlyErrorMessage(401) });
} }
}); });
+36
View File
@@ -1,12 +1,48 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/schedule_overview_screen.dart'; import 'screens/schedule_overview_screen.dart';
import 'services/auth_service.dart'; import 'services/auth_service.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'services/app_navigator.dart'; import 'services/app_navigator.dart';
import 'services/error_log_service.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) {
FlutterError.presentError(details);
void report() => ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'FlutterError.onError',
error: details.exceptionAsString(),
);
// ignore: unawaited_futures
report();
};
PlatformDispatcher.instance.onError = (error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'PlatformDispatcher.onError',
error: '$error\n$stack',
);
return true;
};
runZonedGuarded(() {
runApp(const MainApp()); runApp(const MainApp());
}, (error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'runZonedGuarded',
error: '$error\n$stack',
);
});
} }
class MainApp extends StatelessWidget { class MainApp extends StatelessWidget {
+17 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../services/error_log_service.dart';
import '../widgets/custom_input_field.dart'; import '../widgets/custom_input_field.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
@@ -37,7 +38,17 @@ class _LoginScreenState extends State<LoginScreen> {
final input = _userCodeController.text.trim(); final input = _userCodeController.text.trim();
final parsedUserCode = int.tryParse(input); final parsedUserCode = int.tryParse(input);
if (parsedUserCode == null) { if (parsedUserCode == null) {
throw Exception('User code must be a number'); await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin',
error: 'User code must be a number',
);
if (mounted) {
setState(() {
_errorMessage = 'User code must be a number';
});
}
return;
} }
await _authService.login( await _authService.login(
@@ -50,6 +61,11 @@ class _LoginScreenState extends State<LoginScreen> {
Navigator.of(context).pushReplacementNamed('/schedule'); Navigator.of(context).pushReplacementNamed('/schedule');
} }
} catch (e) { } catch (e) {
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin.catch',
error: e.toString(),
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', ''); _errorMessage = e.toString().replaceAll('Exception: ', '');
@@ -226,8 +226,6 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with Ti
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Diensten'), title: const Text('Diensten'),
@@ -323,7 +321,7 @@ class _ProgressTab extends StatelessWidget {
return Card( return Card(
child: ListTile( child: ListTile(
title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'), title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'),
subtitle: Text('${_formatTime(svc.startTime)}'), subtitle: Text(_formatTime(svc.startTime)),
trailing: TextButton( trailing: TextButton(
onPressed: () async { onPressed: () async {
try { try {
+12 -1
View File
@@ -2,14 +2,23 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart'; import '../models/user.dart';
import 'error_log_service.dart';
/// Authentication Service /// Authentication Service
class AuthService { class AuthService {
static const String _apiBaseUrl = 'http://api.transit.local:5000'; static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
static const String _tokenKey = 'auth_token'; static const String _tokenKey = 'auth_token';
late SharedPreferences _prefs; late SharedPreferences _prefs;
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
Future<void> _initPrefs() async { Future<void> _initPrefs() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
} }
@@ -42,6 +51,7 @@ class AuthService {
return authResponse; return authResponse;
} catch (e) { } catch (e) {
await _reportError('AuthService.login.parse', e);
// Surface the response body to help debug backend field names / formats // Surface the response body to help debug backend field names / formats
throw Exception('Login parse error: $e -- response body: $body'); throw Exception('Login parse error: $e -- response body: $body');
} }
@@ -51,6 +61,7 @@ class AuthService {
throw Exception('Login failed: ${response.statusCode}'); throw Exception('Login failed: ${response.statusCode}');
} }
} catch (e) { } catch (e) {
await _reportError('AuthService.login', e);
throw Exception('Login error: $e'); throw Exception('Login error: $e');
} }
} }
@@ -0,0 +1,26 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class ErrorLogService {
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
static Future<void> report({
required String service,
required String partOfService,
required String error,
}) async {
try {
await http.post(
Uri.parse('$_apiBaseUrl/error_log'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'service': service,
'partOfService': partOfService,
'error': error,
}),
);
} catch (_) {
// Intentionally ignore logging failures.
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
/// Custom exception for authentication errors // Custom exception for authentication errors
import 'app_navigator.dart'; import 'app_navigator.dart';
class UnauthorizedException implements Exception { class UnauthorizedException implements Exception {
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'error_log_service.dart';
import '../models/navigation.dart'; import '../models/navigation.dart';
/// Navigation Service using OpenStreetMap /// Navigation Service using OpenStreetMap
@@ -10,6 +11,13 @@ class NavigationService {
static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/driving'; static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/driving';
/// Get current device location /// Get current device location
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
Future<NavigationPoint?> getCurrentLocation() async { Future<NavigationPoint?> getCurrentLocation() async {
try { try {
final permission = await Geolocator.checkPermission(); final permission = await Geolocator.checkPermission();
@@ -27,6 +35,7 @@ class NavigationService {
name: 'Current Location', name: 'Current Location',
); );
} catch (e) { } catch (e) {
await _reportError('NavigationService.getCurrentLocation', e);
throw Exception('Failed to get location: $e'); throw Exception('Failed to get location: $e');
} }
} }
@@ -88,6 +97,7 @@ class NavigationService {
throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}'); throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
} }
} catch (e) { } catch (e) {
await _reportError('NavigationService.getRoute', e);
throw Exception('Navigation error: $e'); throw Exception('Navigation error: $e');
} }
} }
@@ -152,6 +162,7 @@ class NavigationService {
end: stops.last, end: stops.last,
); );
} catch (e) { } catch (e) {
await _reportError('NavigationService.getRouteThroughStops', e);
throw Exception('Navigation error: $e'); throw Exception('Navigation error: $e');
} }
} }
@@ -252,6 +263,7 @@ class NavigationService {
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops, orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
); );
} catch (e) { } catch (e) {
await _reportError('NavigationService.getOptimizedRouteThroughStops', e);
throw Exception('Navigation error: $e'); throw Exception('Navigation error: $e');
} }
} }
@@ -308,6 +320,7 @@ class NavigationService {
throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}'); throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}');
} }
} catch (e) { } catch (e) {
await _reportError('NavigationService.getMultipleRoutes', e);
throw Exception('Navigation error: $e'); throw Exception('Navigation error: $e');
} }
} }
@@ -358,6 +371,7 @@ class NavigationService {
return NavigationPoint(latitude: lat, longitude: lon, name: display); return NavigationPoint(latitude: lat, longitude: lon, name: display);
} catch (e) { } catch (e) {
await _reportError('NavigationService.geocodePlace', e);
throw Exception('Geocode error: $e'); throw Exception('Geocode error: $e');
} }
} }
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
import '../models/schedule.dart'; import '../models/schedule.dart';
import '../models/navigation.dart'; import '../models/navigation.dart';
import 'auth_service.dart'; import 'auth_service.dart';
import 'error_log_service.dart';
import 'exceptions.dart'; import 'exceptions.dart';
/// Schedule/Route Service /// Schedule/Route Service
@@ -10,6 +11,14 @@ class ScheduleService {
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl'; static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
final _authService = AuthService(); final _authService = AuthService();
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
/// Get public schedule overview for a specific date. /// Get public schedule overview for a specific date.
Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async { Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async {
try { try {
@@ -32,6 +41,7 @@ class ScheduleService {
throw Exception('Failed to load schedules: ${response.statusCode}'); throw Exception('Failed to load schedules: ${response.statusCode}');
} catch (e) { } catch (e) {
await _reportError('ScheduleService.getSchedules', e);
throw Exception('Schedule error: $e'); throw Exception('Schedule error: $e');
} }
} }
@@ -57,6 +67,7 @@ class ScheduleService {
throw Exception('Failed to load recurring schedules: ${response.statusCode}'); throw Exception('Failed to load recurring schedules: ${response.statusCode}');
} catch (e) { } catch (e) {
await _reportError('ScheduleService.getRecurringSchedules', e);
throw Exception('Recurring schedule error: $e'); throw Exception('Recurring schedule error: $e');
} }
} }
@@ -76,6 +87,7 @@ class ScheduleService {
throw Exception('Failed to load schedule stops: ${response.statusCode}'); throw Exception('Failed to load schedule stops: ${response.statusCode}');
} catch (e) { } catch (e) {
await _reportError('ScheduleService.getScheduleStops', e);
throw Exception('Schedule stops error: $e'); throw Exception('Schedule stops error: $e');
} }
} }
@@ -130,6 +142,7 @@ class ScheduleService {
throw Exception('Failed to load schedule: ${response.statusCode}'); throw Exception('Failed to load schedule: ${response.statusCode}');
} }
} catch (e) { } catch (e) {
await _reportError('ScheduleService.getDailySchedule', e);
throw Exception('Schedule error: $e'); throw Exception('Schedule error: $e');
} }
} }
@@ -152,6 +165,7 @@ class ScheduleService {
throw Exception('Failed to load progress: ${response.statusCode}'); throw Exception('Failed to load progress: ${response.statusCode}');
} catch (e) { } catch (e) {
await _reportError('ScheduleService.getScheduleProgress', e);
throw Exception('Schedule progress error: $e'); throw Exception('Schedule progress error: $e');
} }
} }
@@ -177,6 +191,7 @@ class ScheduleService {
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getRouteDetails', e);
throw Exception('Route error: $e'); throw Exception('Route error: $e');
} }
} }
@@ -200,6 +215,7 @@ class ScheduleService {
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.updateRouteStatus', e);
throw Exception('Status update error: $e'); throw Exception('Status update error: $e');
} }
} }
@@ -228,6 +244,7 @@ class ScheduleService {
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.updateScheduleStatus', e);
throw Exception('Schedule status update error: $e'); throw Exception('Schedule status update error: $e');
} }
} }
@@ -266,6 +283,7 @@ class ScheduleService {
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getStops', e);
throw Exception('Get stops error: $e'); throw Exception('Get stops error: $e');
} }
} }
@@ -298,6 +316,7 @@ class ScheduleService {
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.markScheduleStopPassed', e);
throw Exception('Mark stop passed error: $e'); throw Exception('Mark stop passed error: $e');
} }
} }
+28 -2
View File
@@ -1,6 +1,21 @@
version: '3.9' version: '3.9'
services: services:
error_log_api:
build:
context: ./error_log_api
container_name: error_log_api
ports:
- "5003:5003"
environment:
PORT: 5003
PG_HOST: ${POSTGRES_HOST}
PG_PORT: ${POSTGRES_PORT}
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_LOGGER}
PG_PASS: ${POSTGRES_PASSWORD_LOGGER}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
restart: unless-stopped
auth_api: auth_api:
image: ghcr.io/sven491/xtransit/auth_api:latest image: ghcr.io/sven491/xtransit/auth_api:latest
container_name: auth_api container_name: auth_api
@@ -14,7 +29,11 @@ services:
PG_DB: ${POSTGRES_DB} PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_AUTH} PG_USER: ${POSTGRES_USER_AUTH}
PG_PASS: ${POSTGRES_PASSWORD_AUTH} PG_PASS: ${POSTGRES_PASSWORD_AUTH}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_BYPASS: ${DEV_BYPASS:-0} DEV_BYPASS: ${DEV_BYPASS:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped restart: unless-stopped
employee_api: employee_api:
@@ -29,6 +48,9 @@ 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}
depends_on:
- error_log_api
restart: unless-stopped restart: unless-stopped
transit_api: transit_api:
@@ -44,8 +66,10 @@ services:
PG_DB: ${POSTGRES_DB} PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_TRANSIT} PG_USER: ${POSTGRES_USER_TRANSIT}
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT} PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_MOCK: ${DEV_MOCK:-0} DEV_MOCK: ${DEV_MOCK:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped restart: unless-stopped
admin_ui: admin_ui:
@@ -56,7 +80,9 @@ 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}
depends_on: depends_on:
- auth_api - auth_api
- transit_api - transit_api
- error_log_api
restart: unless-stopped restart: unless-stopped
+42 -2
View File
@@ -6,6 +6,44 @@ 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';
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.';
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: 'employee_api',
partOfService,
error,
}),
});
} catch (_) {}
}
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 });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
// Employee onboarding // Employee onboarding
app.post('/createemployee', async (req, res) => { app.post('/createemployee', async (req, res) => {
try { try {
@@ -20,8 +58,9 @@ app.post('/createemployee', async (req, res) => {
return res.status(201).json({ employee: firstname, code: newUser.usercoden }); return res.status(201).json({ employee: firstname, code: newUser.usercoden });
} }
catch (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: 'internal_error' }); res.status(500).json({ error: friendlyErrorMessage(500) });
} }
}) })
@@ -30,8 +69,9 @@ app.get('/health', async (_req, res, _next) => {
return res.status(200).json({endpoint: 'reachable'}) return res.status(200).json({endpoint: 'reachable'})
} }
catch (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({endpoint: 'unreachable'}) res.status(500).json({ endpoint: 'unreachable', error: friendlyErrorMessage(500) })
} }
}) })
+4
View File
@@ -0,0 +1,4 @@
.env
node_modules
npm-debug.log
.DS_Store
+7
View File
@@ -0,0 +1,7 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 5003
CMD ["npm", "start"]
+26
View File
@@ -0,0 +1,26 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
host: process.env.PG_HOST,
port: process.env.PG_PORT,
user: process.env.PG_USER,
password: process.env.PG_PASS,
database: process.env.PG_DB,
options: '-c search_path=data,public',
max: 10,
idleTimeoutMillis: 30000,
});
pool.query('SELECT 1')
.then(() => console.log('PostgreSQL connection OK'))
.catch(err => {
console.error('PostgreSQL connection FAILED');
console.error(err);
process.exit(1);
});
module.exports = {
query: (text, params) => pool.query(text, params),
pool,
};
+139
View File
@@ -0,0 +1,139 @@
const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');
require('dotenv').config();
const db = require('./db');
const app = express();
app.use(express.json({ limit: '64kb' }));
app.use(cors());
const PORT = Number(process.env.PORT || 5003);
const JWT_SECRET = process.env.JWT_SECRET || '';
// Admin verification is done by `auth_api` and carried in the JWT as `isAdmin`.
function toFriendlyError(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.';
}
function normalizeErrorText(value) {
if (!value) return 'Unknown error';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (_) {
return String(value);
}
}
async function insertErrorLog({ service, partOfService, error }) {
const serviceName = String(service || 'unknown_service').slice(0, 120);
const part = partOfService ? String(partOfService).slice(0, 240) : null;
const errorText = String(normalizeErrorText(error)).slice(0, 8000);
await db.query(
'INSERT INTO data.errors ("service", "partOfService", "error") VALUES ($1, $2, $3)',
[serviceName, part, errorText]
);
}
function verifyAdmin(req, res, next) {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
return res.status(401).json({ error: toFriendlyError(401) });
}
try {
const payload = jwt.verify(auth.slice(7), JWT_SECRET);
if (!payload?.isAdmin) {
return res.status(403).json({ error: toFriendlyError(403) });
}
req.user = payload;
return next();
} catch (err) {
return res.status(401).json({ error: toFriendlyError(401) });
}
}
app.get('/health', async (_req, res) => {
res.json({ status: 'ok', service: 'error_log_api' });
});
app.post('/error_log', async (req, res) => {
try {
const service = req.body?.service;
const partOfService = req.body?.partOfService || null;
const error = req.body?.error;
if (!service || !error) {
return res.status(400).json({ error: toFriendlyError(400) });
}
await insertErrorLog({ service, partOfService, error });
return res.status(201).json({ ok: true });
} catch (err) {
console.error('POST /error_log failed:', err);
return res.status(500).json({ error: toFriendlyError(500) });
}
});
app.get('/error_log', verifyAdmin, async (req, res) => {
try {
const limit = Math.min(Number(req.query.limit || 100), 500);
const service = req.query.service ? String(req.query.service) : null;
const partOfService = req.query.partOfService ? String(req.query.partOfService) : null;
const where = [];
const params = [];
if (service) {
params.push(service);
where.push(`"service" = $${params.length}`);
}
if (partOfService) {
params.push(partOfService);
where.push(`"partOfService" ILIKE $${params.length}`);
params[params.length - 1] = `%${partOfService}%`;
}
params.push(limit);
const rows = await db.query(
`SELECT id, "date", "service", "partOfService", "error"
FROM data.errors
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY "date" DESC, id DESC
LIMIT $${params.length}`,
params
);
return res.json({ errors: rows.rows });
} catch (err) {
console.error('GET /error_log failed:', err);
return res.status(500).json({ error: toFriendlyError(500) });
}
});
app.use(async (err, req, res, _next) => {
try {
await insertErrorLog({
service: 'error_log_api',
partOfService: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
} catch (logErr) {
console.error('Failed to log internal error:', logErr);
}
if (res.headersSent) return;
return res.status(500).json({ error: toFriendlyError(500) });
});
app.listen(PORT, () => console.log(`Error Log API listening on ${PORT}`));
module.exports = app;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "error_log_api",
"version": "1.0.0",
"description": "Central error logging API",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.0"
}
}
+39 -11
View File
@@ -9,6 +9,44 @@ const app = express();
app.use(express.json()); app.use(express.json());
app.use(cors()); app.use(cors());
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.';
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: 'transit_api',
partOfService,
error,
}),
});
} catch (_) {}
}
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 });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
// Development mock storage when DEV_MOCK=1 // 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 = { const _mock = {
@@ -90,18 +128,8 @@ if (DEV_MOCK) {
_mock.nextScheduleId = 2; _mock.nextScheduleId = 2;
} }
const adminUserCodes = new Set(
(process.env.ADMIN_USER_CODES || '')
.split(',')
.map(code => code.trim())
.filter(Boolean)
.map(code => Number(code))
.filter(code => Number.isFinite(code))
);
const adminMiddleware = (req, res, next) => { const adminMiddleware = (req, res, next) => {
const userCode = Number(req.user?.userCode); if (!req.user || !req.user.isAdmin) {
if (!Number.isFinite(userCode) || !adminUserCodes.has(userCode)) {
return res.status(403).json({ error: 'forbidden' }); return res.status(403).json({ error: 'forbidden' });
} }