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:
@@ -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,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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
@@ -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) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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) })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 5003
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user