From 7215e7f4727d503fad16c921f8cbaede7ff54da6 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 13 Mar 2026 14:01:06 +0100 Subject: [PATCH] update --- .../migrations/028_add_standesbuchnummer.sql | 2 + backend/src/models/member.model.ts | 2 + backend/src/routes/admin.routes.ts | 4 +- backend/src/services/member.service.ts | 9 ++- .../src/components/admin/FdiskSyncTab.tsx | 13 ++-- frontend/src/pages/MitgliedDetail.tsx | 67 ++++++++++++++++++- frontend/src/services/admin.ts | 2 +- frontend/src/types/member.types.ts | 1 + sync/src/db.ts | 14 +++- sync/src/index.ts | 22 ++++-- 10 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 backend/src/database/migrations/028_add_standesbuchnummer.sql diff --git a/backend/src/database/migrations/028_add_standesbuchnummer.sql b/backend/src/database/migrations/028_add_standesbuchnummer.sql new file mode 100644 index 0000000..d0be9ad --- /dev/null +++ b/backend/src/database/migrations/028_add_standesbuchnummer.sql @@ -0,0 +1,2 @@ +ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS fdisk_standesbuch_nr VARCHAR(32); +CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_fdisk_standesbuch_nr ON mitglieder_profile(fdisk_standesbuch_nr); diff --git a/backend/src/models/member.model.ts b/backend/src/models/member.model.ts index 656e02a..263e8f0 100644 --- a/backend/src/models/member.model.ts +++ b/backend/src/models/member.model.ts @@ -64,6 +64,7 @@ export interface MitgliederProfile { user_id: string; mitglieds_nr: string | null; + fdisk_standesbuch_nr: string | null; dienstgrad: DienstgradEnum | null; dienstgrad_seit: Date | null; funktion: FunktionEnum[]; @@ -172,6 +173,7 @@ export interface MemberFilters { */ export const CreateMemberProfileSchema = z.object({ mitglieds_nr: z.string().max(32).optional(), + fdisk_standesbuch_nr: z.string().max(32).optional(), dienstgrad: z.enum(DIENSTGRAD_VALUES).optional(), dienstgrad_seit: z.coerce.date().optional(), funktion: z.array(z.enum(FUNKTION_VALUES)).default([]), diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index a8aeed5..db3cde2 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -197,13 +197,13 @@ router.post( '/fdisk-sync/trigger', authenticate, requirePermission('admin:access'), - async (_req: Request, res: Response): Promise => { + async (req: Request, res: Response): Promise => { if (!FDISK_SYNC_URL) { res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); return; } try { - const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, {}, { timeout: 5000 }); + const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, req.body, { timeout: 5000 }); res.json({ success: true, data: response.data }); } catch (err: unknown) { if (axios.isAxiosError(err) && err.response?.status === 409) { diff --git a/backend/src/services/member.service.ts b/backend/src/services/member.service.ts index 498a99f..eab68ed 100644 --- a/backend/src/services/member.service.ts +++ b/backend/src/services/member.service.ts @@ -37,6 +37,7 @@ class MemberService { mp.id AS mp_id, mp.user_id AS mp_user_id, mp.mitglieds_nr AS mp_mitglieds_nr, + mp.fdisk_standesbuch_nr AS mp_fdisk_standesbuch_nr, mp.dienstgrad AS mp_dienstgrad, mp.dienstgrad_seit AS mp_dienstgrad_seit, mp.funktion AS mp_funktion, @@ -83,6 +84,7 @@ class MemberService { id: row.mp_id, user_id: row.mp_user_id, mitglieds_nr: row.mp_mitglieds_nr, + fdisk_standesbuch_nr: row.mp_fdisk_standesbuch_nr ?? null, dienstgrad: row.mp_dienstgrad, dienstgrad_seit: row.mp_dienstgrad_seit, funktion: row.mp_funktion ?? [], @@ -283,6 +285,7 @@ class MemberService { INSERT INTO mitglieder_profile ( user_id, mitglieds_nr, + fdisk_standesbuch_nr, dienstgrad, dienstgrad_seit, funktion, @@ -300,8 +303,8 @@ class MemberService { bemerkungen, bild_url ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14, $15, $16, $17, $18 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19 ) RETURNING * `; @@ -309,6 +312,7 @@ class MemberService { const values = [ userId, data.mitglieds_nr ?? null, + data.fdisk_standesbuch_nr ?? null, data.dienstgrad ?? null, data.dienstgrad_seit ?? null, data.funktion ?? [], @@ -387,6 +391,7 @@ class MemberService { const fieldMap: Record = { mitglieds_nr: rest.mitglieds_nr, + fdisk_standesbuch_nr: rest.fdisk_standesbuch_nr, funktion: rest.funktion, status: rest.status, eintrittsdatum: rest.eintrittsdatum, diff --git a/frontend/src/components/admin/FdiskSyncTab.tsx b/frontend/src/components/admin/FdiskSyncTab.tsx index 54a9b25..cf7df25 100644 --- a/frontend/src/components/admin/FdiskSyncTab.tsx +++ b/frontend/src/components/admin/FdiskSyncTab.tsx @@ -1,6 +1,6 @@ -import { useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { - Box, Button, Card, CardContent, Chip, CircularProgress, IconButton, Tooltip, Typography, + Box, Button, Card, CardContent, Checkbox, Chip, CircularProgress, FormControlLabel, IconButton, Tooltip, Typography, } from '@mui/material'; import SyncIcon from '@mui/icons-material/Sync'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; @@ -12,6 +12,7 @@ function FdiskSyncTab() { const queryClient = useQueryClient(); const { showSuccess, showError } = useNotification(); const logBoxRef = useRef(null); + const [force, setForce] = useState(false); const { data, isLoading, isError } = useQuery({ queryKey: ['admin', 'fdisk-sync', 'logs'], @@ -27,7 +28,7 @@ function FdiskSyncTab() { }, [data?.logs.length]); const triggerMutation = useMutation({ - mutationFn: adminApi.fdiskSyncTrigger, + mutationFn: (forceSync: boolean) => adminApi.fdiskSyncTrigger(forceSync), onSuccess: () => { showSuccess('Sync gestartet'); queryClient.invalidateQueries({ queryKey: ['admin', 'fdisk-sync', 'logs'] }); @@ -71,12 +72,16 @@ function FdiskSyncTab() { + setForce(e.target.checked)} />} + label="Alle Mitglieder erzwungen synchronisieren" + /> diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index 7c708f3..69458cd 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -7,6 +7,7 @@ import { CardContent, CardHeader, Avatar, + Autocomplete, Button, Chip, Tabs, @@ -41,9 +42,11 @@ import { MemberWithProfile, StatusEnum, DienstgradEnum, + FunktionEnum, TshirtGroesseEnum, DIENSTGRAD_VALUES, STATUS_VALUES, + FUNKTION_VALUES, TSHIRT_GROESSE_VALUES, STATUS_LABELS, STATUS_COLORS, @@ -239,6 +242,7 @@ function MitgliedDetail() { funktion: member.profile.funktion, status: member.profile.status, eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined, + austrittsdatum: toGermanDate(member.profile.austrittsdatum) || undefined, geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined, telefon_mobil: member.profile.telefon_mobil ?? undefined, telefon_privat: member.profile.telefon_privat ?? undefined, @@ -248,6 +252,7 @@ function MitgliedDetail() { tshirt_groesse: member.profile.tshirt_groesse ?? undefined, schuhgroesse: member.profile.schuhgroesse ?? undefined, bemerkungen: member.profile.bemerkungen ?? undefined, + fdisk_standesbuch_nr: member.profile.fdisk_standesbuch_nr ?? undefined, }); } }, [member]); @@ -263,6 +268,7 @@ function MitgliedDetail() { const payload: UpdateMemberProfileData = { ...formData, eintrittsdatum: formData.eintrittsdatum ? fromGermanDate(formData.eintrittsdatum) || undefined : undefined, + austrittsdatum: formData.austrittsdatum ? fromGermanDate(formData.austrittsdatum) || undefined : undefined, geburtsdatum: formData.geburtsdatum ? fromGermanDate(formData.geburtsdatum) || undefined : undefined, dienstgrad_seit: formData.dienstgrad_seit ? fromGermanDate(formData.dienstgrad_seit) || undefined : undefined, }; @@ -282,12 +288,23 @@ function MitgliedDetail() { // Reset form to current profile values if (member?.profile) { setFormData({ + mitglieds_nr: member.profile.mitglieds_nr ?? undefined, + dienstgrad: member.profile.dienstgrad ?? undefined, + dienstgrad_seit: toGermanDate(member.profile.dienstgrad_seit) || undefined, + funktion: member.profile.funktion, + status: member.profile.status, + eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined, + austrittsdatum: toGermanDate(member.profile.austrittsdatum) || undefined, + geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined, telefon_mobil: member.profile.telefon_mobil ?? undefined, telefon_privat: member.profile.telefon_privat ?? undefined, notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined, notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined, + fuehrerscheinklassen: member.profile.fuehrerscheinklassen, tshirt_groesse: member.profile.tshirt_groesse ?? undefined, schuhgroesse: member.profile.schuhgroesse ?? undefined, + bemerkungen: member.profile.bemerkungen ?? undefined, + fdisk_standesbuch_nr: member.profile.fdisk_standesbuch_nr ?? undefined, }); } }; @@ -542,6 +559,35 @@ function MitgliedDetail() { onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)} InputLabelProps={{ shrink: true }} /> + + handleFieldChange('austrittsdatum', e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + /> + + handleFieldChange('fdisk_standesbuch_nr', e.target.value || undefined)} + /> + + handleFieldChange('funktion', newValue as FunktionEnum[])} + renderInput={(params) => ( + + )} + size="small" + /> ) : ( <> @@ -574,6 +620,13 @@ function MitgliedDetail() { : null } /> + + )} @@ -700,7 +753,19 @@ function MitgliedDetail() { title="Führerscheinklassen" /> - {profile?.fuehrerscheinklassen && profile.fuehrerscheinklassen.length > 0 ? ( + {editMode && canWrite ? ( + handleFieldChange('fuehrerscheinklassen', newValue)} + renderInput={(params) => ( + + )} + size="small" + /> + ) : profile?.fuehrerscheinklassen && profile.fuehrerscheinklassen.length > 0 ? ( {profile.fuehrerscheinklassen.map((k) => ( diff --git a/frontend/src/services/admin.ts b/frontend/src/services/admin.ts index 8a01a20..2f4acfd 100644 --- a/frontend/src/services/admin.ts +++ b/frontend/src/services/admin.ts @@ -28,5 +28,5 @@ export const adminApi = { broadcast: (data: BroadcastPayload) => api.post>('/api/admin/notifications/broadcast', data).then(r => r.data.data), getPingHistory: (serviceId: string) => api.get>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data), fdiskSyncLogs: () => api.get>('/api/admin/fdisk-sync/logs').then(r => r.data.data), - fdiskSyncTrigger: () => api.post>('/api/admin/fdisk-sync/trigger').then(r => r.data.data), + fdiskSyncTrigger: (force = false) => api.post>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data), }; diff --git a/frontend/src/types/member.types.ts b/frontend/src/types/member.types.ts index e6bb85c..8bbf470 100644 --- a/frontend/src/types/member.types.ts +++ b/frontend/src/types/member.types.ts @@ -73,6 +73,7 @@ export interface MitgliederProfile { tshirt_groesse: TshirtGroesseEnum | null; schuhgroesse: string | null; bemerkungen: string | null; + fdisk_standesbuch_nr: string | null; bild_url: string | null; created_at: string; updated_at: string; diff --git a/sync/src/db.ts b/sync/src/db.ts index 3a72958..3c6a51b 100644 --- a/sync/src/db.ts +++ b/sync/src/db.ts @@ -54,7 +54,8 @@ function mapDienstgrad(raw: string): string | null { export async function syncToDatabase( pool: Pool, members: FdiskMember[], - ausbildungen: FdiskAusbildung[] + ausbildungen: FdiskAusbildung[], + force = false ): Promise { const client = await pool.connect(); try { @@ -62,6 +63,7 @@ export async function syncToDatabase( let updated = 0; let unchanged = 0; + let forced = 0; let skipped = 0; for (const member of members) { @@ -161,12 +163,20 @@ export async function syncToDatabase( if (changes.length > 0) { log(`Updated ${member.vorname} ${member.zuname} (${member.standesbuchNr}): ${changes.join(', ')}`); updated++; + } else if (force) { + // Force mode: explicitly update timestamp and log even unchanged rows + await client.query( + `UPDATE mitglieder_profile SET updated_at = NOW() WHERE user_id = $1`, + [userId] + ); + log(`Forced update ${member.vorname} ${member.zuname} (${member.standesbuchNr}): no changes, timestamp refreshed`); + forced++; } else { unchanged++; } } - log(`Members: ${updated} changed, ${unchanged} unchanged, ${skipped} skipped (no dashboard account)`); + log(`Members: ${updated} changed, ${unchanged} unchanged, ${forced} forced, ${skipped} skipped (no dashboard account)`); // Upsert Ausbildungen let ausbildungNew = 0; diff --git a/sync/src/index.ts b/sync/src/index.ts index 4989068..b393032 100644 --- a/sync/src/index.ts +++ b/sync/src/index.ts @@ -46,7 +46,7 @@ function msUntilMidnight(): number { return midnight.getTime() - now.getTime(); } -async function runSync(): Promise { +async function runSync(force = false): Promise { if (syncRunning) { log('Sync already in progress, skipping'); return; @@ -64,9 +64,10 @@ async function runSync(): Promise { }); try { + if (force) log('Force mode: ON'); log('Starting FDISK sync'); const { members, ausbildungen } = await scrapeAll(username, password); - await syncToDatabase(pool, members, ausbildungen); + await syncToDatabase(pool, members, ausbildungen, force); log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen`); } finally { syncRunning = false; @@ -87,9 +88,20 @@ function startHttpServer(port: number) { res.end(JSON.stringify({ running: true, message: 'Sync already in progress' })); return; } - res.writeHead(200); - res.end(JSON.stringify({ started: true })); - runSync().catch(err => log(`ERROR during manual sync: ${err.message}`)); + let body = ''; + req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + req.on('end', () => { + let force = false; + try { + const parsed = JSON.parse(body); + force = parsed?.force === true; + } catch { + // no body or invalid JSON — force stays false + } + res.writeHead(200); + res.end(JSON.stringify({ started: true, force })); + runSync(force).catch(err => log(`ERROR during manual sync: ${err.message}`)); + }); } else { res.writeHead(404); res.end(JSON.stringify({ message: 'Not found' }));