Files
dashboard/sync/src/db.ts
Matthias Hochmeister f5d1f7b061 update
2026-03-13 20:02:46 +01:00

244 lines
9.2 KiB
TypeScript

import { Pool } from 'pg';
import { FdiskMember, FdiskAusbildung } from './types';
function log(msg: string) {
console.log(`[db] ${new Date().toISOString()} ${msg}`);
}
/**
* Map FDISK Dienstgrad (abbreviation or full name) to the DB enum value.
* Returns null if no match found — the field will be left unchanged.
*/
function mapDienstgrad(raw: string): string | null {
const abbrevMap: Record<string, string> = {
'fa': 'Feuerwehranwärter',
'jfm': 'Jugendfeuerwehrmann',
'pfm': 'Probefeuerwehrmann',
'fm': 'Feuerwehrmann',
'ff': 'Feuerwehrfrau',
'ofm': 'Oberfeuerwehrmann',
'off': 'Oberfeuerwehrfrau',
'hfm': 'Hauptfeuerwehrmann',
'hff': 'Hauptfeuerwehrfrau',
'lm': 'Löschmeister',
'olm': 'Oberlöschmeister',
'hlm': 'Hauptlöschmeister',
'bm': 'Brandmeister',
'obm': 'Oberbrandmeister',
'hbm': 'Hauptbrandmeister',
'bi': 'Brandinspektor',
'obi': 'Oberbrandinspektor',
'boi': 'Brandoberinspektor',
'bam': 'Brandamtmann',
'vm': 'Verwaltungsmeister',
'ovm': 'Oberverwaltungsmeister',
'hvm': 'Hauptverwaltungsmeister',
'v': 'Verwalter',
};
const normalized = raw.trim().toLowerCase();
// Direct abbreviation match
if (abbrevMap[normalized]) return abbrevMap[normalized];
// Ehrendienstgrad: starts with 'e', rest maps to a known abbreviation
// e.g. EOLM → Ehren-Oberlöschmeister, EVM → Ehren-Verwaltungsmeister
if (normalized.startsWith('e') && normalized.length > 1) {
const base = abbrevMap[normalized.slice(1)];
if (base) return `Ehren-${base}`;
}
// Full name pass-through (case-insensitive)
const allValues = Object.values(abbrevMap);
const match = allValues.find(v => v.toLowerCase() === normalized);
if (match) return match;
// Also match Ehren- full names
const ehrenMatch = allValues.find(v => `ehren-${v.toLowerCase()}` === normalized);
if (ehrenMatch) return `Ehren-${ehrenMatch}`;
return null;
}
export async function syncToDatabase(
pool: Pool,
members: FdiskMember[],
ausbildungen: FdiskAusbildung[],
force = false
): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
let updated = 0;
let unchanged = 0;
let forced = 0;
let skipped = 0;
for (const member of members) {
// Find the matching mitglieder_profile by fdisk_standesbuch_nr first,
// then fall back to matching by name (given_name + family_name)
const profileResult = await client.query<{ user_id: string }>(
`SELECT mp.user_id
FROM mitglieder_profile mp
JOIN users u ON u.id = mp.user_id
WHERE mp.fdisk_standesbuch_nr = $1
AND u.last_login_at IS NOT NULL`,
[member.standesbuchNr]
);
let userId: string | null = null;
if (profileResult.rows.length > 0) {
userId = profileResult.rows[0].user_id;
} else {
// Fallback: match by name (case-insensitive), only logged-in users
const nameResult = await client.query<{ id: string }>(
`SELECT u.id
FROM users u
JOIN mitglieder_profile mp ON mp.user_id = u.id
WHERE LOWER(u.given_name) = LOWER($1)
AND LOWER(u.family_name) = LOWER($2)
AND u.last_login_at IS NOT NULL`,
[member.vorname, member.zuname]
);
if (nameResult.rows.length > 1) {
log(`WARN: skipping ${member.vorname} ${member.zuname} (Standesbuch-Nr ${member.standesbuchNr}) — duplicate name match (${nameResult.rows.length} users)`);
} else if (nameResult.rows.length === 1) {
userId = nameResult.rows[0].id;
// Store the Standesbuch-Nr now that we found a match
await client.query(
`UPDATE mitglieder_profile SET fdisk_standesbuch_nr = $1 WHERE user_id = $2`,
[member.standesbuchNr, userId]
);
log(`Linked ${member.vorname} ${member.zuname} → Standesbuch-Nr ${member.standesbuchNr}`);
}
}
if (!userId) {
skipped++;
continue;
}
// Fetch current values to detect what actually changed
const currentResult = await client.query<{
status: string;
dienstgrad: string | null;
eintrittsdatum: string | null;
austrittsdatum: string | null;
geburtsdatum: string | null;
}>(
`SELECT status, dienstgrad,
to_char(eintrittsdatum, 'YYYY-MM-DD') AS eintrittsdatum,
to_char(austrittsdatum, 'YYYY-MM-DD') AS austrittsdatum,
to_char(geburtsdatum, 'YYYY-MM-DD') AS geburtsdatum
FROM mitglieder_profile WHERE user_id = $1`,
[userId]
);
const cur = currentResult.rows[0];
// Update mitglieder_profile with FDISK data
const dienstgrad = mapDienstgrad(member.dienstgrad);
await client.query(
`UPDATE mitglieder_profile SET
fdisk_standesbuch_nr = $1,
status = $2,
eintrittsdatum = COALESCE($3::date, eintrittsdatum),
austrittsdatum = $4::date,
geburtsdatum = COALESCE($5::date, geburtsdatum),
${dienstgrad ? 'dienstgrad = $6,' : ''}
updated_at = NOW()
WHERE user_id = ${dienstgrad ? '$7' : '$6'}`,
dienstgrad
? [member.standesbuchNr, member.status, member.eintrittsdatum, member.abmeldedatum, member.geburtsdatum, dienstgrad, userId]
: [member.standesbuchNr, member.status, member.eintrittsdatum, member.abmeldedatum, member.geburtsdatum, userId]
);
// Detect and log what changed
const changes: string[] = [];
if (cur) {
if (cur.status !== member.status)
changes.push(`Status ${cur.status}${member.status}`);
if (dienstgrad && cur.dienstgrad !== dienstgrad)
changes.push(`Dienstgrad ${cur.dienstgrad ?? '—'}${dienstgrad}`);
if (member.eintrittsdatum && cur.eintrittsdatum !== member.eintrittsdatum)
changes.push(`Eintrittsdatum ${cur.eintrittsdatum ?? '—'}${member.eintrittsdatum}`);
if (cur.austrittsdatum !== (member.abmeldedatum ?? null))
changes.push(`Austrittsdatum ${cur.austrittsdatum ?? '—'}${member.abmeldedatum ?? '—'}`);
if (member.geburtsdatum && cur.geburtsdatum !== member.geburtsdatum)
changes.push(`Geburtsdatum ${cur.geburtsdatum ?? '—'}${member.geburtsdatum}`);
}
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 ${member.vorname} ${member.zuname} (${member.standesbuchNr}): ${member.status}, ${dienstgrad ?? member.dienstgrad}, Eintritt ${member.eintrittsdatum ?? '—'}`);
forced++;
} else {
log(`OK ${member.vorname} ${member.zuname} (${member.standesbuchNr}): ${member.status}, ${dienstgrad ?? member.dienstgrad}, Eintritt ${member.eintrittsdatum ?? '—'}`);
unchanged++;
}
}
log(`Members: ${updated} changed, ${unchanged} unchanged, ${forced} forced, ${skipped} skipped (no dashboard account)`);
// Upsert Ausbildungen
let ausbildungNew = 0;
let ausbildungUpdated = 0;
let ausbildungSkipped = 0;
for (const ausb of ausbildungen) {
// Find user_id by standesbuch_nr
const result = await client.query<{ user_id: string }>(
`SELECT user_id FROM mitglieder_profile WHERE fdisk_standesbuch_nr = $1`,
[ausb.standesbuchNr]
);
if (result.rows.length === 0) {
ausbildungSkipped++;
continue;
}
const userId = result.rows[0].user_id;
// xmax = 0 means a fresh INSERT (not an update of an existing row)
const upsertResult = await client.query<{ was_inserted: boolean }>(
`INSERT INTO ausbildung (user_id, kursname, kurs_datum, ablaufdatum, ort, bemerkung, fdisk_sync_key)
VALUES ($1, $2, $3::date, $4::date, $5, $6, $7)
ON CONFLICT (user_id, fdisk_sync_key) DO UPDATE SET
kursname = EXCLUDED.kursname,
kurs_datum = EXCLUDED.kurs_datum,
ablaufdatum = EXCLUDED.ablaufdatum,
ort = EXCLUDED.ort,
bemerkung = EXCLUDED.bemerkung,
updated_at = NOW()
RETURNING (xmax = 0) AS was_inserted`,
[userId, ausb.kursname, ausb.kursDatum, ausb.ablaufdatum, ausb.ort, ausb.bemerkung, ausb.syncKey]
);
if (upsertResult.rows[0]?.was_inserted) {
log(`New Ausbildung: ${ausb.standesbuchNr}${ausb.kursname}${ausb.kursDatum ? ` (${ausb.kursDatum})` : ''}`);
ausbildungNew++;
} else {
log(`Updated Ausbildung: ${ausb.standesbuchNr}${ausb.kursname}${ausb.kursDatum ? ` (${ausb.kursDatum})` : ''}`);
ausbildungUpdated++;
}
}
await client.query('COMMIT');
log(`Ausbildungen: ${ausbildungNew} neu, ${ausbildungUpdated} unverändert, ${ausbildungSkipped} übersprungen`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}