diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index b903d29..4bdf169 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -9,6 +9,7 @@ on: - 'auth_api/**' - 'employee_api/**' - 'transit_api/**' + - 'error_log_api/**' - 'admin_ui/**' - '.github/workflows/docker-build.yml' pull_request: @@ -153,6 +154,48 @@ jobs: cache-from: type=gha 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: runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index 0235fb0..b65feb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env /*.env transit_api/.env +error_log_api/.env # Local build / node_modules admin_ui/node_modules/ diff --git a/README.md b/README.md index 471912b..9f722a8 100644 --- a/README.md +++ b/README.md @@ -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) - `employee_api` — Employee onboarding / management (standaard port 4000) - `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). @@ -77,9 +78,14 @@ POSTGRES_USER_EMPLOYEE=employee_user POSTGRES_PASSWORD_EMPLOYEE=strong_password_employee POSTGRES_USER_TRANSIT=transit_user 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=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. @@ -218,6 +224,30 @@ Database schema en migraties zitten niet in deze repository. Zorg dat je: - de juiste schema's (`workers`, `routes`, etc.) aanmaakt - 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): ```bash diff --git a/admin_ui/README.md b/admin_ui/README.md index 1fe3afc..90c98cc 100644 --- a/admin_ui/README.md +++ b/admin_ui/README.md @@ -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 ✅ **JWT Authentication** - Secure authentication with role-based access ✅ **Production Ready** - Docker containerized with runtime env configuration +✅ **Error Log Viewer** - Inspect centralized error logs from all services ## Local Development @@ -31,6 +32,7 @@ Create a `.env` file in the `admin_ui` directory: ```env VITE_AUTH_API=http://localhost:5000 VITE_TRANSIT_API=http://localhost:5001 +VITE_ERROR_LOG_API=http://localhost:5003 ``` ### 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 - `FleetManager.jsx` - View fleet vehicles - `LinkStop.jsx` - Link stops to bus lines +- `ErrorLogViewer.jsx` - Central error log viewer ### API Integration The app communicates with: - **Auth API** (/login, /me) - **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses) +- **Error Log API** (/error_log) 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 4. Set strong `JWT_SECRET` environment variable 5. Configure `ADMIN_USER_CODES` with actual admin user IDs +6. Configure `VITE_ERROR_LOG_API` if you use a custom logging endpoint ## Troubleshooting diff --git a/admin_ui/docker-entrypoint.sh b/admin_ui/docker-entrypoint.sh index 0ae31bf..9bdfc4c 100755 --- a/admin_ui/docker-entrypoint.sh +++ b/admin_ui/docker-entrypoint.sh @@ -4,7 +4,8 @@ set -e cat > /usr/share/nginx/html/env-config.js < + ) diff --git a/admin_ui/src/api.js b/admin_ui/src/api.js index 2413a38..da698a9 100644 --- a/admin_ui/src/api.js +++ b/admin_ui/src/api.js @@ -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 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 }) +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) => { const client = axios.create({ baseURL: TRANSIT_API, @@ -35,6 +64,11 @@ export const transitClient = (token) => { // reload to let App show the Login screen 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) { // ignore } @@ -44,3 +78,10 @@ export const transitClient = (token) => { return client } + +export const errorLogClient = (token) => axios.create({ + baseURL: ERROR_LOG_API, + headers: token ? { Authorization: `Bearer ${token}` } : {}, +}) + +export { reportClientError } diff --git a/admin_ui/src/components/BusLinesManager.jsx b/admin_ui/src/components/BusLinesManager.jsx index 557f128..413ddc3 100644 --- a/admin_ui/src/components/BusLinesManager.jsx +++ b/admin_ui/src/components/BusLinesManager.jsx @@ -11,7 +11,7 @@ export default function BusLinesManager({ token, user }) { const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('') const [description, setDescription] = useState('') const [message, setMessage] = useState(null) - const [isAdmin] = useState(Boolean(user?.userCode)) + const [isAdmin] = useState(Boolean(user?.isAdmin)) const [deleteConfirm, setDeleteConfirm] = useState(null) const [editingLine, setEditingLine] = useState(null) diff --git a/admin_ui/src/components/ErrorLogViewer.jsx b/admin_ui/src/components/ErrorLogViewer.jsx new file mode 100644 index 0000000..410b798 --- /dev/null +++ b/admin_ui/src/components/ErrorLogViewer.jsx @@ -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 ( + + + + Error log + Laatste foutmeldingen uit de centrale logging service. + + + {loading ? 'Laden...' : 'Ververs'} + + + + + setService(e.target.value)} /> + setPartOfService(e.target.value)} /> + + + {message && {message}} + + + {logs.map(log => ( + + {log.service} + {formatDate(log.date)} + {log.partOfService || '—'} + {log.error} + + ))} + + + {!logs.length && !message && Nog geen foutmeldingen gevonden.} + + ) +} diff --git a/admin_ui/src/components/FixedScheduleEditor.jsx b/admin_ui/src/components/FixedScheduleEditor.jsx index 77d860d..808896e 100644 --- a/admin_ui/src/components/FixedScheduleEditor.jsx +++ b/admin_ui/src/components/FixedScheduleEditor.jsx @@ -172,7 +172,7 @@ const FixedScheduleEditor = ({ const [availableDrivers, setAvailableDrivers] = useState([]) const [pendingBatchData, setPendingBatchData] = useState(null) - const isAdmin = Boolean(user?.userCode) + const isAdmin = Boolean(user?.isAdmin) const resetForm = () => { setEditingScheduleId(null) diff --git a/admin_ui/src/components/FleetManager.jsx b/admin_ui/src/components/FleetManager.jsx index 5a7d6d9..e1096b7 100644 --- a/admin_ui/src/components/FleetManager.jsx +++ b/admin_ui/src/components/FleetManager.jsx @@ -9,7 +9,7 @@ export default function FleetManager({ token, user }) { const [licensePlate, setLicensePlate] = useState('') const [message, setMessage] = useState(null) const [canCreateBus, setCanCreateBus] = useState(true) - const [isAdmin] = useState(Boolean(user?.userCode)) + const [isAdmin] = useState(Boolean(user?.isAdmin)) const [editingBus, setEditingBus] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null) diff --git a/admin_ui/src/components/LinkStop.jsx b/admin_ui/src/components/LinkStop.jsx index fdcfcf4..1cf78ea 100644 --- a/admin_ui/src/components/LinkStop.jsx +++ b/admin_ui/src/components/LinkStop.jsx @@ -13,7 +13,7 @@ export default function LinkStop({ token, user }) { const [draggedItem, setDraggedItem] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null) - const isAdmin = Boolean(user?.userCode) + const isAdmin = Boolean(user?.isAdmin) const nextOrder = useMemo(() => stops.length + 1, [stops]) diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx index 0dc283a..565ed88 100644 --- a/admin_ui/src/components/ScheduleMaker.jsx +++ b/admin_ui/src/components/ScheduleMaker.jsx @@ -11,7 +11,7 @@ export default function ScheduleMaker({ token, user }) { const [drivers, setDrivers] = useState([]) const [selectedDriverFilter, setSelectedDriverFilter] = useState('') 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 loadScheduleTemplates = async () => { diff --git a/admin_ui/src/components/StopsManager.jsx b/admin_ui/src/components/StopsManager.jsx index 692be04..e8d35b8 100644 --- a/admin_ui/src/components/StopsManager.jsx +++ b/admin_ui/src/components/StopsManager.jsx @@ -8,7 +8,7 @@ export default function StopsManager({ token, user }) { const [lat, setLat] = useState('') const [lon, setLon] = useState('') const [message, setMessage] = useState(null) - const [isAdmin] = useState(Boolean(user?.userCode)) + const [isAdmin] = useState(Boolean(user?.isAdmin)) const [deleteConfirm, setDeleteConfirm] = useState(null) const [editingStop, setEditingStop] = useState(null) diff --git a/auth_api/index.js b/auth_api/index.js index 5f2ec80..a6869ed 100644 --- a/auth_api/index.js +++ b/auth_api/index.js @@ -18,7 +18,51 @@ app.use((req, res, 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 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) => { @@ -33,13 +77,15 @@ app.post('/login', async (req, res) => { const ok = await bcrypt.compare(password, user.password); 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' }); - 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) { + await reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(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); res.json({ user: payload }); } 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) }); } }); diff --git a/bus_terminal/lib/main.dart b/bus_terminal/lib/main.dart index 048e061..72342c1 100644 --- a/bus_terminal/lib/main.dart +++ b/bus_terminal/lib/main.dart @@ -1,12 +1,48 @@ +import 'dart:async'; +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'screens/login_screen.dart'; import 'screens/schedule_overview_screen.dart'; import 'services/auth_service.dart'; import 'theme/app_theme.dart'; import 'services/app_navigator.dart'; +import 'services/error_log_service.dart'; void main() { - runApp(const MainApp()); + 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()); + }, (error, stack) { + // ignore: unawaited_futures + ErrorLogService.report( + service: 'bus_terminal', + partOfService: 'runZonedGuarded', + error: '$error\n$stack', + ); + }); } class MainApp extends StatelessWidget { diff --git a/bus_terminal/lib/screens/login_screen.dart b/bus_terminal/lib/screens/login_screen.dart index ec35aed..4bc55a1 100644 --- a/bus_terminal/lib/screens/login_screen.dart +++ b/bus_terminal/lib/screens/login_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../services/auth_service.dart'; +import '../services/error_log_service.dart'; import '../widgets/custom_input_field.dart'; class LoginScreen extends StatefulWidget { @@ -37,7 +38,17 @@ class _LoginScreenState extends State { final input = _userCodeController.text.trim(); final parsedUserCode = int.tryParse(input); 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( @@ -50,6 +61,11 @@ class _LoginScreenState extends State { Navigator.of(context).pushReplacementNamed('/schedule'); } } catch (e) { + await ErrorLogService.report( + service: 'bus_terminal', + partOfService: 'LoginScreen._handleLogin.catch', + error: e.toString(), + ); if (mounted) { setState(() { _errorMessage = e.toString().replaceAll('Exception: ', ''); diff --git a/bus_terminal/lib/screens/schedule_overview_screen.dart b/bus_terminal/lib/screens/schedule_overview_screen.dart index b3211f8..d512cba 100644 --- a/bus_terminal/lib/screens/schedule_overview_screen.dart +++ b/bus_terminal/lib/screens/schedule_overview_screen.dart @@ -226,8 +226,6 @@ class _ScheduleOverviewScreenState extends State with Ti @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Scaffold( appBar: AppBar( title: const Text('Diensten'), @@ -323,7 +321,7 @@ class _ProgressTab extends StatelessWidget { return Card( child: ListTile( title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'), - subtitle: Text('${_formatTime(svc.startTime)}'), + subtitle: Text(_formatTime(svc.startTime)), trailing: TextButton( onPressed: () async { try { diff --git a/bus_terminal/lib/services/auth_service.dart b/bus_terminal/lib/services/auth_service.dart index 868f7bf..dd4aed9 100644 --- a/bus_terminal/lib/services/auth_service.dart +++ b/bus_terminal/lib/services/auth_service.dart @@ -2,14 +2,23 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; +import 'error_log_service.dart'; /// Authentication Service 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'; late SharedPreferences _prefs; + Future _reportError(String partOfService, Object error) { + return ErrorLogService.report( + service: 'bus_terminal', + partOfService: partOfService, + error: error.toString(), + ); + } + Future _initPrefs() async { _prefs = await SharedPreferences.getInstance(); } @@ -42,6 +51,7 @@ class AuthService { return authResponse; } catch (e) { + await _reportError('AuthService.login.parse', e); // Surface the response body to help debug backend field names / formats throw Exception('Login parse error: $e -- response body: $body'); } @@ -51,6 +61,7 @@ class AuthService { throw Exception('Login failed: ${response.statusCode}'); } } catch (e) { + await _reportError('AuthService.login', e); throw Exception('Login error: $e'); } } diff --git a/bus_terminal/lib/services/error_log_service.dart b/bus_terminal/lib/services/error_log_service.dart new file mode 100644 index 0000000..6765842 --- /dev/null +++ b/bus_terminal/lib/services/error_log_service.dart @@ -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 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. + } + } +} diff --git a/bus_terminal/lib/services/exceptions.dart b/bus_terminal/lib/services/exceptions.dart index 7818b7c..46e4aac 100644 --- a/bus_terminal/lib/services/exceptions.dart +++ b/bus_terminal/lib/services/exceptions.dart @@ -1,4 +1,4 @@ -/// Custom exception for authentication errors +// Custom exception for authentication errors import 'app_navigator.dart'; class UnauthorizedException implements Exception { diff --git a/bus_terminal/lib/services/navigation_service.dart b/bus_terminal/lib/services/navigation_service.dart index d18684b..9c7bb3f 100644 --- a/bus_terminal/lib/services/navigation_service.dart +++ b/bus_terminal/lib/services/navigation_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:geolocator/geolocator.dart'; import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; +import 'error_log_service.dart'; import '../models/navigation.dart'; /// Navigation Service using OpenStreetMap @@ -10,6 +11,13 @@ class NavigationService { static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/driving'; /// Get current device location + Future _reportError(String partOfService, Object error) { + return ErrorLogService.report( + service: 'bus_terminal', + partOfService: partOfService, + error: error.toString(), + ); + } Future getCurrentLocation() async { try { final permission = await Geolocator.checkPermission(); @@ -27,6 +35,7 @@ class NavigationService { name: 'Current Location', ); } catch (e) { + await _reportError('NavigationService.getCurrentLocation', 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}'); } } catch (e) { + await _reportError('NavigationService.getRoute', e); throw Exception('Navigation error: $e'); } } @@ -152,6 +162,7 @@ class NavigationService { end: stops.last, ); } catch (e) { + await _reportError('NavigationService.getRouteThroughStops', e); throw Exception('Navigation error: $e'); } } @@ -252,6 +263,7 @@ class NavigationService { orderedStops: orderedStops.isNotEmpty ? orderedStops : stops, ); } catch (e) { + await _reportError('NavigationService.getOptimizedRouteThroughStops', e); throw Exception('Navigation error: $e'); } } @@ -308,6 +320,7 @@ class NavigationService { throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}'); } } catch (e) { + await _reportError('NavigationService.getMultipleRoutes', e); throw Exception('Navigation error: $e'); } } @@ -358,6 +371,7 @@ class NavigationService { return NavigationPoint(latitude: lat, longitude: lon, name: display); } catch (e) { + await _reportError('NavigationService.geocodePlace', e); throw Exception('Geocode error: $e'); } } diff --git a/bus_terminal/lib/services/schedule_service.dart b/bus_terminal/lib/services/schedule_service.dart index 60e002f..0157bca 100644 --- a/bus_terminal/lib/services/schedule_service.dart +++ b/bus_terminal/lib/services/schedule_service.dart @@ -3,6 +3,7 @@ import 'package:http/http.dart' as http; import '../models/schedule.dart'; import '../models/navigation.dart'; import 'auth_service.dart'; +import 'error_log_service.dart'; import 'exceptions.dart'; /// Schedule/Route Service @@ -10,6 +11,14 @@ class ScheduleService { static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl'; final _authService = AuthService(); + Future _reportError(String partOfService, Object error) { + return ErrorLogService.report( + service: 'bus_terminal', + partOfService: partOfService, + error: error.toString(), + ); + } + /// Get public schedule overview for a specific date. Future> getSchedules(DateTime date, {int? lineId}) async { try { @@ -32,6 +41,7 @@ class ScheduleService { throw Exception('Failed to load schedules: ${response.statusCode}'); } catch (e) { + await _reportError('ScheduleService.getSchedules', e); throw Exception('Schedule error: $e'); } } @@ -57,6 +67,7 @@ class ScheduleService { throw Exception('Failed to load recurring schedules: ${response.statusCode}'); } catch (e) { + await _reportError('ScheduleService.getRecurringSchedules', e); throw Exception('Recurring schedule error: $e'); } } @@ -76,6 +87,7 @@ class ScheduleService { throw Exception('Failed to load schedule stops: ${response.statusCode}'); } catch (e) { + await _reportError('ScheduleService.getScheduleStops', e); throw Exception('Schedule stops error: $e'); } } @@ -130,6 +142,7 @@ class ScheduleService { throw Exception('Failed to load schedule: ${response.statusCode}'); } } catch (e) { + await _reportError('ScheduleService.getDailySchedule', e); throw Exception('Schedule error: $e'); } } @@ -152,6 +165,7 @@ class ScheduleService { throw Exception('Failed to load progress: ${response.statusCode}'); } catch (e) { + await _reportError('ScheduleService.getScheduleProgress', e); throw Exception('Schedule progress error: $e'); } } @@ -177,6 +191,7 @@ class ScheduleService { } } catch (e) { if (e is UnauthorizedException) rethrow; + await _reportError('ScheduleService.getRouteDetails', e); throw Exception('Route error: $e'); } } @@ -200,6 +215,7 @@ class ScheduleService { } } catch (e) { if (e is UnauthorizedException) rethrow; + await _reportError('ScheduleService.updateRouteStatus', e); throw Exception('Status update error: $e'); } } @@ -228,6 +244,7 @@ class ScheduleService { } } catch (e) { if (e is UnauthorizedException) rethrow; + await _reportError('ScheduleService.updateScheduleStatus', e); throw Exception('Schedule status update error: $e'); } } @@ -266,6 +283,7 @@ class ScheduleService { } } catch (e) { if (e is UnauthorizedException) rethrow; + await _reportError('ScheduleService.getStops', e); throw Exception('Get stops error: $e'); } } @@ -298,6 +316,7 @@ class ScheduleService { } } catch (e) { if (e is UnauthorizedException) rethrow; + await _reportError('ScheduleService.markScheduleStopPassed', e); throw Exception('Mark stop passed error: $e'); } } diff --git a/docker-compose.yml b/docker-compose.yml index 2edd27e..92b230b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,21 @@ version: '3.9' - 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: image: ghcr.io/sven491/xtransit/auth_api:latest container_name: auth_api @@ -14,7 +29,11 @@ services: PG_DB: ${POSTGRES_DB} PG_USER: ${POSTGRES_USER_AUTH} PG_PASS: ${POSTGRES_PASSWORD_AUTH} + ADMIN_USER_CODES: ${ADMIN_USER_CODES} DEV_BYPASS: ${DEV_BYPASS:-0} + ERROR_LOG_API_URL: ${ERROR_LOG_API_URL} + depends_on: + - error_log_api restart: unless-stopped employee_api: @@ -29,6 +48,9 @@ services: PG_DB: ${POSTGRES_DB} PG_USER: ${POSTGRES_USER_EMPLOYEE} PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE} + ERROR_LOG_API_URL: ${ERROR_LOG_API_URL} + depends_on: + - error_log_api restart: unless-stopped transit_api: @@ -44,8 +66,10 @@ services: PG_DB: ${POSTGRES_DB} PG_USER: ${POSTGRES_USER_TRANSIT} PG_PASS: ${POSTGRES_PASSWORD_TRANSIT} - ADMIN_USER_CODES: ${ADMIN_USER_CODES} DEV_MOCK: ${DEV_MOCK:-0} + ERROR_LOG_API_URL: ${ERROR_LOG_API_URL} + depends_on: + - error_log_api restart: unless-stopped admin_ui: @@ -56,7 +80,9 @@ services: environment: AUTH_API: ${AUTH_API} TRANSIT_API: ${TRANSIT_API} + ERROR_LOG_API: ${ERROR_LOG_API} depends_on: - auth_api - transit_api + - error_log_api restart: unless-stopped \ No newline at end of file diff --git a/employee_api/index.js b/employee_api/index.js index cd83cee..b05c417 100644 --- a/employee_api/index.js +++ b/employee_api/index.js @@ -6,6 +6,44 @@ require('dotenv').config(); const app = express(); 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 app.post('/createemployee', async (req, res) => { try { @@ -20,8 +58,9 @@ app.post('/createemployee', async (req, res) => { return res.status(201).json({ employee: firstname, code: newUser.usercoden }); } catch (err) { - console.error(err); - res.status(500).json({ error: 'internal_error' }); + void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) }); + console.error(err); + 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'}) } catch (err) { + void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) }); console.error(err); - res.status(500).json({endpoint: 'unreachable'}) + res.status(500).json({ endpoint: 'unreachable', error: friendlyErrorMessage(500) }) } }) diff --git a/error_log_api/.dockerignore b/error_log_api/.dockerignore new file mode 100644 index 0000000..033e6f8 --- /dev/null +++ b/error_log_api/.dockerignore @@ -0,0 +1,4 @@ +.env +node_modules +npm-debug.log +.DS_Store diff --git a/error_log_api/Dockerfile b/error_log_api/Dockerfile new file mode 100644 index 0000000..4505c0c --- /dev/null +++ b/error_log_api/Dockerfile @@ -0,0 +1,7 @@ +FROM node:18-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --production +COPY . . +EXPOSE 5003 +CMD ["npm", "start"] diff --git a/error_log_api/db.js b/error_log_api/db.js new file mode 100644 index 0000000..95ba93a --- /dev/null +++ b/error_log_api/db.js @@ -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, +}; diff --git a/error_log_api/index.js b/error_log_api/index.js new file mode 100644 index 0000000..618f212 --- /dev/null +++ b/error_log_api/index.js @@ -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; diff --git a/error_log_api/package.json b/error_log_api/package.json new file mode 100644 index 0000000..e0b7d0a --- /dev/null +++ b/error_log_api/package.json @@ -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" + } +} \ No newline at end of file diff --git a/transit_api/index.js b/transit_api/index.js index 2ba0914..c1ead49 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -9,6 +9,44 @@ const app = express(); app.use(express.json()); app.use(cors()); +const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log'; + +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 const DEV_MOCK = process.env.DEV_MOCK === '1'; const _mock = { @@ -90,18 +128,8 @@ if (DEV_MOCK) { _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 userCode = Number(req.user?.userCode); - if (!Number.isFinite(userCode) || !adminUserCodes.has(userCode)) { + if (!req.user || !req.user.isAdmin) { return res.status(403).json({ error: 'forbidden' }); }
Laatste foutmeldingen uit de centrale logging service.