From 0a5402a9e5f0c3a4ddea8acaa2f723ebad80e5ca Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Sat, 18 Apr 2026 18:31:22 +0200 Subject: [PATCH] feat(admin): add system logs viewer, tabbed data management, fix AT20 sync --- backend/src/routes/admin.routes.ts | 48 +- .../components/admin/DataManagementTab.tsx | 459 ++++++++++-------- .../src/components/admin/SystemLogsTab.tsx | 164 +++++++ frontend/src/pages/AdminDashboard.tsx | 11 +- frontend/src/services/admin.ts | 11 + sync/src/db.ts | 35 ++ sync/src/index.ts | 3 +- 7 files changed, 526 insertions(+), 205 deletions(-) create mode 100644 frontend/src/components/admin/SystemLogsTab.tsx diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index 675103a..13f7f6c 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -15,12 +15,13 @@ import { Router, Request, Response } from 'express'; import { z } from 'zod'; import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; import { auditExport } from '../middleware/audit.middleware'; import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service'; import cleanupService from '../services/cleanup.service'; -import atemschutzService from '../services/atemschutz.service'; import userService from '../services/user.service'; import pool from '../config/database'; import logger from '../utils/logger'; @@ -208,10 +209,6 @@ router.post( try { const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, req.body, { timeout: 5000 }); res.json({ success: true, data: response.data }); - // Fire-and-forget: sync AT20 courses to atemschutz_traeger after FDISK data is written - atemschutzService.syncLehrgangFromKurse().catch(err => - logger.error('AT20 Atemschutz-Sync fehlgeschlagen', { error: err }) - ); } catch (err: unknown) { if (axios.isAxiosError(err) && err.response?.status === 409) { res.status(409).json({ success: false, message: 'Sync already in progress' }); @@ -222,6 +219,47 @@ router.post( } ); +// --------------------------------------------------------------------------- +// GET /api/admin/logs — read Winston application log files +// --------------------------------------------------------------------------- + +const LOG_DIR = path.join(__dirname, '../../logs'); + +router.get( + '/logs', + authenticate, + requirePermission('admin:view'), + async (req: Request, res: Response): Promise => { + try { + const file = req.query.file === 'error' ? 'error.log' : 'combined.log'; + const maxLines = Math.min(Math.max(Number(req.query.lines) || 500, 1), 2000); + const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase() : ''; + + const filePath = path.join(LOG_DIR, file); + + if (!fs.existsSync(filePath)) { + res.json({ success: true, data: { file, lines: [], total: 0 } }); + return; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + let allLines = content.split('\n').filter(l => l.trim().length > 0); + + if (search) { + allLines = allLines.filter(l => l.toLowerCase().includes(search)); + } + + const total = allLines.length; + const lines = allLines.slice(-maxLines); + + res.json({ success: true, data: { file, lines, total } }); + } catch (error) { + logger.error('Failed to read log file', { error }); + res.status(500).json({ success: false, message: 'Failed to read log file' }); + } + } +); + // --------------------------------------------------------------------------- // Cleanup / Data Management endpoints // --------------------------------------------------------------------------- diff --git a/frontend/src/components/admin/DataManagementTab.tsx b/frontend/src/components/admin/DataManagementTab.tsx index 379012b..414857d 100644 --- a/frontend/src/components/admin/DataManagementTab.tsx +++ b/frontend/src/components/admin/DataManagementTab.tsx @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react'; import { - Box, Paper, Typography, TextField, Button, Alert, - CircularProgress, Divider, Autocomplete, Chip, + Box, Paper, Typography, TextField, Button, Alert, Tabs, Tab, + CircularProgress, Autocomplete, Chip, } from '@mui/material'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; @@ -20,33 +20,53 @@ interface CleanupSection { defaultDays: number; } -const SECTIONS: CleanupSection[] = [ - { key: 'notifications', label: 'Benachrichtigungen', description: 'Alte Benachrichtigungen aller Benutzer entfernen.', defaultDays: 90 }, - { key: 'audit-log', label: 'Audit-Log', description: 'Alte Audit-Log Eintraege entfernen.', defaultDays: 365 }, - { key: 'events', label: 'Veranstaltungen', description: 'Vergangene Veranstaltungen entfernen (nach Enddatum).', defaultDays: 365 }, - { key: 'bookings', label: 'Fahrzeugbuchungen', description: 'Abgeschlossene oder stornierte Buchungen entfernen.', defaultDays: 180 }, - { key: 'orders', label: 'Bestellungen', description: 'Abgeschlossene Bestellungen entfernen.', defaultDays: 365 }, - { key: 'vehicle-history', label: 'Fahrzeug-Wartungslog', description: 'Alte Fahrzeug-Wartungseintraege entfernen.', defaultDays: 730 }, - { key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 }, - { key: 'checklist-history', label: 'Checklisten-Historie', description: 'Alte Checklisten-Ausfuehrungen entfernen.', defaultDays: 365 }, -]; - interface ResetSection { key: string; label: string; description: string; } -const RESET_SECTIONS: ResetSection[] = [ - { key: 'reset-bestellungen', label: 'Bestellungen zuruecksetzen', description: 'Alle Bestellungen, Positionen, Dateien, Erinnerungen und Historie loeschen und Nummern zuruecksetzen.' }, - { key: 'reset-ausruestung-anfragen', label: 'Interne Bestellungen zuruecksetzen', description: 'Alle internen Bestellungen und zugehoerige Positionen loeschen und Nummern zuruecksetzen.' }, - { key: 'reset-issues', label: 'Issues zuruecksetzen', description: 'Alle Issues und Kommentare loeschen und Nummern zuruecksetzen.' }, - { key: 'reset-checklist-history', label: 'Checklisten-Historie zuruecksetzen', description: 'Alle Checklisten-Ausfuehrungen und Faelligkeiten loeschen und Nummern zuruecksetzen.' }, - { key: 'reset-buchhaltung-transaktionen', label: 'Buchhaltung: Transaktionen loeschen', description: 'Alle Buchungen und Transaktionen loeschen und Nummerierung zuruecksetzen. Konten und Haushaltsjahre bleiben erhalten.' }, - { key: 'reset-buchhaltung-konten', label: 'Buchhaltung: Konten loeschen', description: 'Alle Konten und alle zugehoerigen Transaktionen loeschen und Nummerierung zuruecksetzen. Haushaltsjahre bleiben erhalten.' }, - { key: 'reset-buchhaltung-bankkonten', label: 'Buchhaltung: Bankkonten loeschen', description: 'Alle Bankkonten loeschen und Nummerierung zuruecksetzen.' }, - { key: 'reset-persoenliche-ausruestung', label: 'Persoenliche Ausruestung zuruecksetzen', description: 'Alle persoenlichen Ausruestungszuweisungen loeschen. Zuordnungen in Anfragen werden zurueckgesetzt.' }, -]; +// ── Grouped by feature area ── + +const TAB_CLEANUP: Record = { + system: [ + { key: 'notifications', label: 'Benachrichtigungen', description: 'Alte Benachrichtigungen aller Benutzer entfernen.', defaultDays: 90 }, + { key: 'audit-log', label: 'Audit-Log', description: 'Alte Audit-Log Eintraege entfernen.', defaultDays: 365 }, + ], + kalender: [ + { key: 'events', label: 'Veranstaltungen', description: 'Vergangene Veranstaltungen entfernen (nach Enddatum).', defaultDays: 365 }, + { key: 'bookings', label: 'Fahrzeugbuchungen', description: 'Abgeschlossene oder stornierte Buchungen entfernen.', defaultDays: 180 }, + ], + fahrzeuge: [ + { key: 'vehicle-history', label: 'Fahrzeug-Wartungslog', description: 'Alte Fahrzeug-Wartungseintraege entfernen.', defaultDays: 730 }, + { key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 }, + ], + bestellungen: [ + { key: 'orders', label: 'Bestellungen', description: 'Abgeschlossene Bestellungen entfernen.', defaultDays: 365 }, + ], + checklisten: [ + { key: 'checklist-history', label: 'Checklisten-Historie', description: 'Alte Checklisten-Ausfuehrungen entfernen.', defaultDays: 365 }, + ], +}; + +const TAB_RESET: Record = { + fahrzeuge: [ + { key: 'reset-persoenliche-ausruestung', label: 'Persoenliche Ausruestung zuruecksetzen', description: 'Alle persoenlichen Ausruestungszuweisungen loeschen. Zuordnungen in Anfragen werden zurueckgesetzt.' }, + ], + bestellungen: [ + { key: 'reset-bestellungen', label: 'Bestellungen zuruecksetzen', description: 'Alle Bestellungen, Positionen, Dateien, Erinnerungen und Historie loeschen und Nummern zuruecksetzen.' }, + { key: 'reset-ausruestung-anfragen', label: 'Interne Bestellungen zuruecksetzen', description: 'Alle internen Bestellungen und zugehoerige Positionen loeschen und Nummern zuruecksetzen.' }, + ], + buchhaltung: [ + { key: 'reset-buchhaltung-transaktionen', label: 'Buchhaltung: Transaktionen loeschen', description: 'Alle Buchungen und Transaktionen loeschen und Nummerierung zuruecksetzen. Konten und Haushaltsjahre bleiben erhalten.' }, + { key: 'reset-buchhaltung-konten', label: 'Buchhaltung: Konten loeschen', description: 'Alle Konten und alle zugehoerigen Transaktionen loeschen und Nummerierung zuruecksetzen. Haushaltsjahre bleiben erhalten.' }, + { key: 'reset-buchhaltung-bankkonten', label: 'Buchhaltung: Bankkonten loeschen', description: 'Alle Bankkonten loeschen und Nummerierung zuruecksetzen.' }, + ], + checklisten: [ + { key: 'reset-checklist-history', label: 'Checklisten-Historie zuruecksetzen', description: 'Alle Checklisten-Ausfuehrungen und Faelligkeiten loeschen und Nummern zuruecksetzen.' }, + { key: 'reset-issues', label: 'Issues zuruecksetzen', description: 'Alle Issues und Kommentare loeschen und Nummern zuruecksetzen.' }, + ], +}; interface SectionState { days: number; @@ -54,8 +74,22 @@ interface SectionState { loading: boolean; } +const ALL_CLEANUP = Object.values(TAB_CLEANUP).flat(); +const ALL_RESET = Object.values(TAB_RESET).flat(); + +const TABS = [ + { key: 'fdisk', label: 'FDISK' }, + { key: 'system', label: 'System' }, + { key: 'kalender', label: 'Kalender' }, + { key: 'fahrzeuge', label: 'Fahrzeuge & Ausruestung' }, + { key: 'bestellungen', label: 'Bestellungen' }, + { key: 'buchhaltung', label: 'Buchhaltung' }, + { key: 'checklisten', label: 'Checklisten & Issues' }, +]; + export default function DataManagementTab() { const { showSuccess, showError } = useNotification(); + const [subTab, setSubTab] = useState(0); // ── FDISK purge ── const { data: users = [], isLoading: usersLoading } = useQuery({ @@ -102,16 +136,17 @@ export default function DataManagementTab() { } }, [selectedUsers, showSuccess, showError]); + // ── Cleanup state (age-based) ── const [states, setStates] = useState>(() => - Object.fromEntries(SECTIONS.map(s => [s.key, { days: s.defaultDays, previewCount: null, loading: false }])) + Object.fromEntries(ALL_CLEANUP.map(s => [s.key, { days: s.defaultDays, previewCount: null, loading: false }])) ); const [confirmDialog, setConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null); const [deleting, setDeleting] = useState(false); - // Reset sections state + // ── Reset state (truncate) ── const [resetStates, setResetStates] = useState>(() => - Object.fromEntries(RESET_SECTIONS.map(s => [s.key, { previewCount: null, loading: false }])) + Object.fromEntries(ALL_RESET.map(s => [s.key, { previewCount: null, loading: false }])) ); const [resetConfirmDialog, setResetConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null); const [resetDeleting, setResetDeleting] = useState(false); @@ -179,85 +214,223 @@ export default function DataManagementTab() { } }, [resetConfirmDialog, showSuccess, showError]); - return ( - - Datenverwaltung - - Alte Daten bereinigen, um die Datenbank schlank zu halten. Geloeschte Daten koennen nicht wiederhergestellt werden. - + // ── Render helpers ── - {/* FDISK data purge */} - - - FDISK-Daten loeschen - - - Loescht alle FDISK-synchronisierten Daten der ausgewaehlten Benutzer: Profilfelder, Ausbildungen, - Befoerderungen, Untersuchungen und Fahrgenehmigungen. Beim naechsten FDISK-Sync werden - die Daten erneut importiert. - + const renderCleanupSection = (section: CleanupSection) => { + const s = states[section.key]; + return ( + + {section.label} + {section.description} - - !selectedUsers.some(s => s.id === u.id))} - loading={usersLoading} - value={null} - inputValue={fdiskInput} - onInputChange={(_e, val, reason) => { - if (reason === 'input') setFdiskInput(val); + + { + const v = parseInt(e.target.value, 10); + if (v > 0) updateState(section.key, { days: v, previewCount: null }); }} - onChange={(_e, user) => { - if (user) { - handleAddUser(user); - setFdiskInput(''); - } - }} - getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`} - isOptionEqualToValue={(a, b) => a.id === b.id} - sx={{ minWidth: 320, flex: 1 }} - renderInput={(params) => ( - - )} + sx={{ width: 160 }} + inputProps={{ min: 1, max: 3650 }} /> - - {selectedUsers.length > 0 && ( - <> - - {selectedUsers.map(u => ( - handleRemoveUser(u.id)} + {s.previewCount !== null && ( + <> + 0 ? 'warning' : 'info'} sx={{ py: 0 }}> + {s.previewCount} {s.previewCount === 1 ? 'Eintrag' : 'Eintraege'} gefunden + + + {s.previewCount > 0 && ( + - + startIcon={} + onClick={() => setConfirmDialog({ key: section.key, label: section.label, count: s.previewCount! })} + > + Loeschen + + )} + + )} + + + ); + }; + + const renderResetSection = (section: ResetSection) => { + const rs = resetStates[section.key]; + return ( + + {section.label} + {section.description} + + + + + {rs?.previewCount !== null && rs?.previewCount !== undefined && ( + <> + 0 ? 'warning' : 'info'} sx={{ py: 0 }}> + {rs.previewCount} {rs.previewCount === 1 ? 'Eintrag' : 'Eintraege'} vorhanden + + + {rs.previewCount > 0 && ( + + )} + + )} + + + ); + }; + + const renderTabContent = (tabKey: string) => { + const cleanups = TAB_CLEANUP[tabKey] ?? []; + const resets = TAB_RESET[tabKey] ?? []; + + return ( + + {cleanups.length > 0 && ( + <> + + Alte Daten bereinigen. Geloeschte Daten koennen nicht wiederhergestellt werden. + + {cleanups.map(renderCleanupSection)} )} - + {cleanups.length > 0 && resets.length > 0 && ( + + Zuruecksetzen + + )} + {resets.map(renderResetSection)} + + ); + }; + const currentTabKey = TABS[subTab]?.key; + + return ( + + setSubTab(v)} + variant="scrollable" + scrollButtons="auto" + sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} + > + {TABS.map((t) => )} + + + {/* FDISK tab — special layout with user selector */} + {currentTabKey === 'fdisk' && ( + + + + FDISK-Daten loeschen + + + Loescht alle FDISK-synchronisierten Daten der ausgewaehlten Benutzer: Profilfelder, Ausbildungen, + Befoerderungen, Untersuchungen und Fahrgenehmigungen. Beim naechsten FDISK-Sync werden + die Daten erneut importiert. + + + + !selectedUsers.some(s => s.id === u.id))} + loading={usersLoading} + value={null} + inputValue={fdiskInput} + onInputChange={(_e, val, reason) => { + if (reason === 'input') setFdiskInput(val); + }} + onChange={(_e, user) => { + if (user) { + handleAddUser(user); + setFdiskInput(''); + } + }} + getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`} + isOptionEqualToValue={(a, b) => a.id === b.id} + sx={{ minWidth: 320, flex: 1 }} + renderInput={(params) => ( + + )} + /> + + + + + {selectedUsers.length > 0 && ( + <> + + {selectedUsers.map(u => ( + handleRemoveUser(u.id)} + size="small" + /> + ))} + + + + + + )} + + + )} + + {/* Feature-area tabs */} + {currentTabKey && currentTabKey !== 'fdisk' && renderTabContent(currentTabKey)} + + {/* Shared confirm dialogs */} !purging && setPurgeConfirmOpen(false)} @@ -283,63 +456,6 @@ export default function DataManagementTab() { isLoading={purging} /> - {SECTIONS.map((section, idx) => { - const s = states[section.key]; - return ( - - {section.label} - {section.description} - - - { - const v = parseInt(e.target.value, 10); - if (v > 0) updateState(section.key, { days: v, previewCount: null }); - }} - sx={{ width: 160 }} - inputProps={{ min: 1, max: 3650 }} - /> - - - - {s.previewCount !== null && ( - <> - 0 ? 'warning' : 'info'} sx={{ py: 0 }}> - {s.previewCount} {s.previewCount === 1 ? 'Eintrag' : 'Eintraege'} gefunden - - - {s.previewCount > 0 && ( - - )} - - )} - - - {idx < SECTIONS.length - 1 && } - - ); - })} - setConfirmDialog(null)} @@ -356,55 +472,6 @@ export default function DataManagementTab() { isLoading={deleting} /> - {/* ---- Reset / Truncate sections ---- */} - - Daten zuruecksetzen - - Alle Eintraege loeschen und Nummern (IDs) auf 1 zuruecksetzen. Abhaengige Daten werden ebenfalls geloescht. - - - {RESET_SECTIONS.map((section) => { - const rs = resetStates[section.key]; - return ( - - {section.label} - {section.description} - - - - - {rs?.previewCount !== null && rs?.previewCount !== undefined && ( - <> - 0 ? 'warning' : 'info'} sx={{ py: 0 }}> - {rs.previewCount} {rs.previewCount === 1 ? 'Eintrag' : 'Eintraege'} vorhanden - - - {rs.previewCount > 0 && ( - - )} - - )} - - - ); - })} - setResetConfirmDialog(null)} diff --git a/frontend/src/components/admin/SystemLogsTab.tsx b/frontend/src/components/admin/SystemLogsTab.tsx new file mode 100644 index 0000000..24e7d34 --- /dev/null +++ b/frontend/src/components/admin/SystemLogsTab.tsx @@ -0,0 +1,164 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { + Box, Card, CardContent, IconButton, TextField, ToggleButton, ToggleButtonGroup, + Tooltip, Typography, CircularProgress, +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { useQuery } from '@tanstack/react-query'; +import { adminApi } from '../../services/admin'; +import { useNotification } from '../../contexts/NotificationContext'; + +const LINE_OPTIONS = [100, 250, 500, 1000] as const; + +export default function SystemLogsTab() { + const { showSuccess, showError } = useNotification(); + const logBoxRef = useRef(null); + + const [file, setFile] = useState<'combined' | 'error'>('combined'); + const [lines, setLines] = useState(500); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + // Debounce the search input + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 400); + return () => clearTimeout(t); + }, [search]); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['admin', 'system-logs', file, lines, debouncedSearch], + queryFn: () => adminApi.getSystemLogs(file, lines, debouncedSearch), + refetchInterval: 5000, + }); + + // Auto-scroll to bottom on new data + useEffect(() => { + if (logBoxRef.current) { + logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight; + } + }, [data?.lines.length]); + + const copyLogs = useCallback(() => { + const text = (data?.lines ?? []).join('\n'); + navigator.clipboard.writeText(text).then( + () => showSuccess('Logs kopiert'), + () => showError('Kopieren fehlgeschlagen'), + ); + }, [data?.lines, showSuccess, showError]); + + const getLineColor = (line: string): string | undefined => { + if (line.includes('"level":"error"') || line.includes('[error]')) return 'error.light'; + if (line.includes('"level":"warn"') || line.includes('[warn]')) return 'warning.light'; + return undefined; + }; + + return ( + + + + + System Logs + + Anwendungsprotokolle des Backend-Servers. Aktualisiert sich automatisch alle 5 Sekunden. + + + + { if (v) setFile(v); }} + size="small" + > + Alle + Nur Fehler + + + { if (v !== null) setLines(v); }} + size="small" + > + {LINE_OPTIONS.map((n) => ( + {n} + ))} + + + + + + + + setSearch(e.target.value)} + sx={{ flex: 1, maxWidth: 400 }} + placeholder="Filtern nach Text..." + /> + + {data ? `${data.lines.length} von ${data.total} Zeilen` : ''} + + + + + + + + + + + {isLoading && ( + + + + )} + {isError && ( + + Log-Dateien konnten nicht gelesen werden. + + )} + {!isLoading && !isError && ( + + {(data?.lines ?? []).length === 0 ? ( + Noch keine Logs vorhanden. + ) : ( + (data?.lines ?? []).map((line, i) => ( + + {line} + + )) + )} + + )} + + + + ); +} diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 42aae5d..1245ffd 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -11,6 +11,7 @@ import ServiceModeTab from '../components/admin/ServiceModeTab'; import PermissionMatrixTab from '../components/admin/PermissionMatrixTab'; import DataManagementTab from '../components/admin/DataManagementTab'; import FdiskSyncTab from '../components/admin/FdiskSyncTab'; +import SystemLogsTab from '../components/admin/SystemLogsTab'; import { usePermissionContext } from '../contexts/PermissionContext'; interface TabPanelProps { @@ -36,7 +37,7 @@ function SubTabs({ labels, children }: { labels: string[]; children: React.React ); } -const ADMIN_TAB_COUNT = 4; +const ADMIN_TAB_COUNT = 5; function AdminDashboard() { const navigate = useNavigate(); @@ -66,6 +67,7 @@ function AdminDashboard() { + @@ -85,8 +87,11 @@ function AdminDashboard() { - - {[, ]} + + + + + {[, ]} diff --git a/frontend/src/services/admin.ts b/frontend/src/services/admin.ts index e09c9ef..60e75bc 100644 --- a/frontend/src/services/admin.ts +++ b/frontend/src/services/admin.ts @@ -16,6 +16,12 @@ export interface FdiskSyncLogsResponse { logs: FdiskSyncLogEntry[]; } +export interface SystemLogsResponse { + file: string; + lines: string[]; + total: number; +} + export const adminApi = { getServices: () => api.get>('/api/admin/services').then(r => r.data.data), createService: (data: { name: string; url: string }) => api.post>('/api/admin/services', data).then(r => r.data.data), @@ -32,4 +38,9 @@ export const adminApi = { fdiskSyncTrigger: (force = false) => api.post>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data), purgeFdiskData: (userId: string) => api.delete>(`/api/admin/users/${userId}/fdisk-data`).then(r => r.data.data), purgeUserData: (userId: string) => api.post>>(`/api/admin/users/${userId}/purge-data`).then(r => r.data.data), + getSystemLogs: (file = 'combined', lines = 500, search = '') => { + const params = new URLSearchParams({ file, lines: String(lines) }); + if (search) params.set('search', search); + return api.get>(`/api/admin/logs?${params}`).then(r => r.data.data); + }, }; diff --git a/sync/src/db.ts b/sync/src/db.ts index e79948a..0f6f62a 100644 --- a/sync/src/db.ts +++ b/sync/src/db.ts @@ -289,6 +289,41 @@ export async function syncToDatabase( } } +/** + * Scans the ausbildung table for AT20 courses with erfolgscode = 'mit Erfolg' + * and upserts atemschutz_traeger records accordingly. + * Must run AFTER syncToDatabase() has committed — i.e. on the same pool, outside the transaction. + */ +export async function syncAT20ToAtemschutz(pool: Pool): Promise { + // First, log a sample of what's actually stored so we can verify the filter strings match. + const sample = await pool.query<{ kurs_kurzbezeichnung: string | null; erfolgscode: string | null; count: string }>( + `SELECT kurs_kurzbezeichnung, erfolgscode, COUNT(*)::text AS count + FROM ausbildung + WHERE kurs_kurzbezeichnung IS NOT NULL + GROUP BY kurs_kurzbezeichnung, erfolgscode + ORDER BY count DESC + LIMIT 20` + ); + log(`AT20-Sync: kurs_kurzbezeichnung/erfolgscode distribution (top 20):`); + for (const row of sample.rows) { + log(` kurzbezeichnung=${JSON.stringify(row.kurs_kurzbezeichnung)} erfolgscode=${JSON.stringify(row.erfolgscode)} count=${row.count}`); + } + + const result = await pool.query<{ rowCount: number }>( + `INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum) + SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum) + FROM ausbildung a + WHERE TRIM(a.kurs_kurzbezeichnung) = 'AT20' + AND TRIM(a.erfolgscode) = 'mit Erfolg' + GROUP BY a.user_id + ON CONFLICT (user_id) DO UPDATE + SET atemschutz_lehrgang = true, + lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum), + updated_at = NOW()` + ); + log(`AT20-Sync: ${result.rowCount ?? 0} atemschutz_traeger rows upserted`); +} + async function syncAusbildungen( client: PoolClient, ausbildungen: FdiskAusbildung[], diff --git a/sync/src/index.ts b/sync/src/index.ts index 06e22d0..74e0a61 100644 --- a/sync/src/index.ts +++ b/sync/src/index.ts @@ -2,7 +2,7 @@ import 'dotenv/config'; import * as http from 'http'; import { Pool } from 'pg'; import { scrapeAll } from './scraper'; -import { syncToDatabase } from './db'; +import { syncToDatabase, syncAT20ToAtemschutz } from './db'; // In-memory log ring buffer — last 500 lines captured from all modules const LOG_BUFFER_MAX = 500; @@ -70,6 +70,7 @@ async function runSync(force = false): Promise { const { members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen } = await scrapeAll(username, password); await syncToDatabase(pool, members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen, force); log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen, ${befoerderungen.length} Beförderungen, ${untersuchungen.length} Untersuchungen, ${fahrgenehmigungen.length} Fahrgenehmigungen`); + await syncAT20ToAtemschutz(pool); } finally { syncRunning = false; await pool.end();