BLZ-Bankdaten: Laufzeit-Auto-Update ins Volume + Einstellungen-Seite
Statt npm-Rebuild-Wartung aktualisiert sich der Bankleitzahlen-Datensatz jetzt zur Laufzeit. Ein Scheduler (taeglich 03:30 + Catch-up 90s) prueft gemaess konfigurierbarem Intervall und laedt current.json/next.json von npm/jsDelivr (Paket bankdata-germany) in das neue Bind-Mount-Volume BANKDATA_DIR (./data/bankdata -> /app/bankdata). Lookup bevorzugt den Volume-Datensatz vor den ins Image gebackenen Daten (Fallback). Es wird kein Fremdcode ausgefuehrt - nur JSON gelesen und die current+next-Delta-Logik nachgebaut. Validierung (>=1000 Eintraege, Format) + atomarer Write (tmp+rename) schuetzen den guten Stand vor Muell. Datenschutz: Der Updater sendet keine Kundendaten, laedt nur eine oeffentliche Datendatei; abschaltbar; bei Fehler/ohne Egress greift Builtin. Neue Einstellungen-Seite /settings/bank-data zeigt Datenstand, Update- Verfuegbarkeit und bietet "Jetzt aktualisieren" + Auto-Update-Schalter + Intervall. Endpoints GET /api/settings/blz, POST /api/settings/blz/update-now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ DB_DATA_DIR=./data/db
|
|||||||
UPLOADS_DIR=./data/uploads
|
UPLOADS_DIR=./data/uploads
|
||||||
FACTORY_DEFAULTS_DIR=./data/factory-defaults
|
FACTORY_DEFAULTS_DIR=./data/factory-defaults
|
||||||
BACKUPS_DIR=./data/backups
|
BACKUPS_DIR=./data/backups
|
||||||
|
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
|
||||||
|
BANKDATA_DIR=./data/bankdata
|
||||||
|
|
||||||
# ============== DATENBANK ==============
|
# ============== DATENBANK ==============
|
||||||
# Der App-User (DB_USER) wird beim ersten Start automatisch von MariaDB
|
# Der App-User (DB_USER) wird beim ersten Start automatisch von MariaDB
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ npm-debug.log*
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
bankdata/
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ COPY backend/factory-defaults /app/factory-defaults-builtin
|
|||||||
COPY backend/scripts /app/scripts
|
COPY backend/scripts /app/scripts
|
||||||
|
|
||||||
# Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback)
|
# Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback)
|
||||||
RUN mkdir -p uploads factory-defaults prisma/backups
|
RUN mkdir -p uploads factory-defaults prisma/backups bankdata
|
||||||
|
|
||||||
# Healthcheck
|
# Healthcheck
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { isValidIBAN } from 'ibantools';
|
import { isValidIBAN } from 'ibantools';
|
||||||
import { bankDataByIBAN } from 'bankdata-germany';
|
import * as blzData from '../services/blzData.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IBAN-Lookup: prüft die Prüfziffer (mod-97, offline über ibantools) und liefert
|
* IBAN-Lookup: prüft die Prüfziffer (mod-97, offline über ibantools) und liefert
|
||||||
* – für deutsche IBANs – BIC und Banknamen aus der Bundesbank-Bankleitzahlen-
|
* – für deutsche IBANs – BIC und Banknamen aus der Bundesbank-Bankleitzahlen-
|
||||||
* datei (offline über bankdata-germany). Die IBAN verlässt NICHT den Server; es
|
* datei (offline; siehe blzData.service – Volume-Datensatz bevorzugt, sonst die
|
||||||
* findet KEIN externer Request statt. Zurückgegeben werden nur öffentliche
|
* ins Image gebackenen Daten). Die IBAN verlässt NICHT den Server; es findet
|
||||||
|
* KEIN externer Request statt. Zurückgegeben werden nur öffentliche
|
||||||
* Bankverzeichnis-Daten (BIC/Name), keine kundenbezogenen Informationen.
|
* Bankverzeichnis-Daten (BIC/Name), keine kundenbezogenen Informationen.
|
||||||
*
|
*
|
||||||
* Antwortformen (immer HTTP 200, sofern eine IBAN übergeben wurde):
|
* Antwortformen (immer HTTP 200, sofern eine IBAN übergeben wurde):
|
||||||
@@ -32,7 +33,7 @@ export async function lookupIban(req: AuthRequest, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const country = iban.slice(0, 2);
|
const country = iban.slice(0, 2);
|
||||||
const bank = country === 'DE' ? bankDataByIBAN(iban) : null;
|
const bank = country === 'DE' ? blzData.lookupByIban(iban) : null;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import * as blzData from '../services/blzData.service.js';
|
||||||
|
|
||||||
|
// Status der BLZ-/Bankdaten (installierte Version, Quelle, Auto-Update-Stand).
|
||||||
|
export async function getStatus(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await blzData.getStatus();
|
||||||
|
res.json({ success: true, data: status } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Laden des BLZ-Status',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jetzt prüfen/aktualisieren (manueller Trigger). ?force=1 lädt auch bei
|
||||||
|
// gleicher Version neu.
|
||||||
|
export async function updateNow(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const force = req.query.force === '1' || req.query.force === 'true';
|
||||||
|
const result = await blzData.runUpdate(force);
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'AppSetting',
|
||||||
|
resourceId: 'blz-data',
|
||||||
|
label: `BLZ-Bankdaten: ${result.message}`,
|
||||||
|
});
|
||||||
|
const status = await blzData.getStatus();
|
||||||
|
res.json({ success: true, data: { result, status } } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(502).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'BLZ-Aktualisierung fehlgeschlagen',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
|||||||
import { downloadFile } from './controllers/fileDownload.controller.js';
|
import { downloadFile } from './controllers/fileDownload.controller.js';
|
||||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||||
|
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
@@ -486,4 +487,5 @@ app.listen(PORT as number, LISTEN_ADDR, () => {
|
|||||||
startBirthdayScheduler();
|
startBirthdayScheduler();
|
||||||
startContractStatusScheduler();
|
startContractStatusScheduler();
|
||||||
startSecurityMonitorScheduler();
|
startSecurityMonitorScheduler();
|
||||||
|
startBlzUpdateScheduler();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
|||||||
import * as appSettingController from '../controllers/appSetting.controller.js';
|
import * as appSettingController from '../controllers/appSetting.controller.js';
|
||||||
import * as backupController from '../controllers/backup.controller.js';
|
import * as backupController from '../controllers/backup.controller.js';
|
||||||
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
|
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
|
||||||
|
import * as blzDataController from '../controllers/blzData.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
// Multer für Backup-Upload (in Memory speichern)
|
// Multer für Backup-Upload (in Memory speichern)
|
||||||
@@ -115,6 +116,24 @@ router.get(
|
|||||||
backupController.getBackupLogDetail
|
backupController.getBackupLogDetail
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ==================== BLZ-/BANKDATEN ====================
|
||||||
|
|
||||||
|
// Status (installierte Version, Quelle, Auto-Update-Stand)
|
||||||
|
router.get(
|
||||||
|
'/blz',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings:read'),
|
||||||
|
blzDataController.getStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jetzt prüfen/aktualisieren (?force=1 lädt auch bei gleicher Version)
|
||||||
|
router.post(
|
||||||
|
'/blz/update-now',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings:update'),
|
||||||
|
blzDataController.updateNow,
|
||||||
|
);
|
||||||
|
|
||||||
// Rate-Limit-Verwaltung (Admin)
|
// Rate-Limit-Verwaltung (Admin)
|
||||||
router.get(
|
router.get(
|
||||||
'/rate-limits/active',
|
'/rate-limits/active',
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const DEFAULT_SETTINGS: Record<string, string> = {
|
|||||||
// Ausweis-Ablauf: Fristenschwellen (in Tagen)
|
// Ausweis-Ablauf: Fristenschwellen (in Tagen)
|
||||||
documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage)
|
documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage)
|
||||||
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
|
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
|
||||||
|
// BLZ-/Bankdaten-Auto-Update (Bundesbank-Bankleitzahlen via bankdata-germany).
|
||||||
|
// Der Updater lädt nur eine öffentliche Datendatei – keine Kundendaten.
|
||||||
|
blzAutoUpdateEnabled: 'true', // Auto-Update an/aus
|
||||||
|
blzUpdateIntervalDays: '30', // Prüf-/Update-Intervall in Tagen
|
||||||
};
|
};
|
||||||
|
|
||||||
// Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE
|
// Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE
|
||||||
@@ -90,8 +94,22 @@ export function validateSettingValue(key: string, rawValue: string): { ok: true;
|
|||||||
return { ok: true, value: trimmed };
|
return { ok: true, value: trimmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BLZ-Update-Intervall: mind. 1 Tag, höchstens 365 (verhindert Dauer-Polling
|
||||||
|
// bzw. faktisch nie).
|
||||||
|
if (key === 'blzUpdateIntervalDays') {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (!/^\d+$/.test(trimmed)) {
|
||||||
|
return { ok: false, error: 'Das Intervall muss eine positive ganze Zahl (Tage) sein.' };
|
||||||
|
}
|
||||||
|
const n = parseInt(trimmed, 10);
|
||||||
|
if (n < 1 || n > 365) {
|
||||||
|
return { ok: false, error: 'Das Intervall muss zwischen 1 und 365 Tagen liegen.' };
|
||||||
|
}
|
||||||
|
return { ok: true, value: String(n) };
|
||||||
|
}
|
||||||
|
|
||||||
// Bool-Settings
|
// Bool-Settings
|
||||||
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') {
|
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled' || key === 'blzAutoUpdateEnabled') {
|
||||||
const trimmed = rawValue.trim().toLowerCase();
|
const trimmed = rawValue.trim().toLowerCase();
|
||||||
if (trimmed !== 'true' && trimmed !== 'false') {
|
if (trimmed !== 'true' && trimmed !== 'false') {
|
||||||
return { ok: false, error: `${key} muss 'true' oder 'false' sein.` };
|
return { ok: false, error: `${key} muss 'true' oder 'false' sein.` };
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { getSetting, getSettingBool, setSetting } from './appSetting.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BLZ-/Bankdaten-Service.
|
||||||
|
*
|
||||||
|
* Liefert BIC + Banknamen zu einer deutschen BLZ/IBAN. Zwei Datenquellen,
|
||||||
|
* in dieser Reihenfolge:
|
||||||
|
* 1. VOLUME – ein zur Laufzeit aktualisierter Datensatz unter BANKDATA_DIR
|
||||||
|
* (Bind-Mount, siehe docker-compose). Wird vom Auto-Updater
|
||||||
|
* befüllt.
|
||||||
|
* 2. BUILTIN – die ins Image gebackenen JSON-Daten des npm-Pakets
|
||||||
|
* `bankdata-germany` (Fallback, immer vorhanden).
|
||||||
|
*
|
||||||
|
* Es wird KEIN Paketcode ausgeführt – nur die reinen JSON-Datendateien
|
||||||
|
* (current.json = { "<BLZ>": ["Name","BIC"] }, next.json = Delta der nächsten
|
||||||
|
* Periode) werden gelesen und mit eigener Logik indiziert.
|
||||||
|
*
|
||||||
|
* Datenschutz: Beim Lookup verlässt KEINE IBAN den Server. Nur der
|
||||||
|
* Auto-Updater macht ausgehende Requests – und lädt dabei lediglich eine
|
||||||
|
* öffentliche Datendatei (keine Kundendaten).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Kompiliert nach CommonJS – das native `require` ist zur Laufzeit verfügbar
|
||||||
|
// und wird nur genutzt, um den Pfad der gebackenen Paketdaten aufzulösen.
|
||||||
|
declare const require: NodeRequire;
|
||||||
|
|
||||||
|
// ---- Pfade / Quellen (per Env überschreibbar) ----
|
||||||
|
const BANKDATA_DIR = process.env.BANKDATA_DIR || path.join(process.cwd(), 'bankdata');
|
||||||
|
const DATASET_FILE = path.join(BANKDATA_DIR, 'blz-dataset.json');
|
||||||
|
const PACKAGE = 'bankdata-germany';
|
||||||
|
const CDN_BASE = process.env.BLZ_CDN_BASE || 'https://cdn.jsdelivr.net/npm';
|
||||||
|
const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.org';
|
||||||
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
// ---- Typen ----
|
||||||
|
type BankTuple = [string, string]; // [bankName, bic]
|
||||||
|
type CurrentData = Record<string, BankTuple>;
|
||||||
|
interface NextData {
|
||||||
|
valid: string;
|
||||||
|
upsert: Record<string, BankTuple>;
|
||||||
|
remove: string[];
|
||||||
|
}
|
||||||
|
interface RawDataset {
|
||||||
|
current: CurrentData;
|
||||||
|
next: NextData;
|
||||||
|
}
|
||||||
|
interface LoadedDataset extends RawDataset {
|
||||||
|
source: 'volume' | 'builtin';
|
||||||
|
version: string;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
}
|
||||||
|
export interface BankInfo {
|
||||||
|
bankName: string;
|
||||||
|
bic: string;
|
||||||
|
blz: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- In-Memory-Cache (per mtime invalidiert) ----
|
||||||
|
let cache: { dataset: LoadedDataset; combined: CurrentData; key: string } | null = null;
|
||||||
|
|
||||||
|
function builtinDataDir(): string {
|
||||||
|
// require.resolve liefert .../dist/cjs/main.js → data/ liegt daneben.
|
||||||
|
const main = require.resolve(PACKAGE);
|
||||||
|
return path.join(path.dirname(main), 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtinVersion(): string {
|
||||||
|
try {
|
||||||
|
const main = require.resolve(PACKAGE);
|
||||||
|
// main = <root>/dist/cjs/main.js → package.json zwei Ebenen höher.
|
||||||
|
const pkgPath = path.join(path.dirname(main), '..', '..', 'package.json');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||||
|
return typeof pkg.version === 'string' ? pkg.version : 'unbekannt';
|
||||||
|
} catch {
|
||||||
|
return 'unbekannt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(file: string): any {
|
||||||
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den aktiven Datensatz (Volume bevorzugt, sonst Builtin). Cached anhand
|
||||||
|
* der mtime der Volume-Datei bzw. eines statischen Keys für Builtin.
|
||||||
|
*/
|
||||||
|
function loadDataset(): LoadedDataset {
|
||||||
|
// Volume vorhanden?
|
||||||
|
let volumeMtime: number | null = null;
|
||||||
|
try {
|
||||||
|
volumeMtime = fs.statSync(DATASET_FILE).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
volumeMtime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = volumeMtime !== null ? `volume:${volumeMtime}` : 'builtin';
|
||||||
|
if (cache && cache.key === key) return cache.dataset;
|
||||||
|
|
||||||
|
let dataset: LoadedDataset;
|
||||||
|
if (volumeMtime !== null) {
|
||||||
|
try {
|
||||||
|
const raw: any = readJson(DATASET_FILE);
|
||||||
|
// Metadaten VOR der Assertion lesen (die narrowt raw auf RawDataset).
|
||||||
|
const version = typeof raw.version === 'string' ? raw.version : 'unbekannt';
|
||||||
|
const fetchedAt = typeof raw.fetchedAt === 'string' ? raw.fetchedAt : null;
|
||||||
|
assertValidRawDataset(raw);
|
||||||
|
dataset = {
|
||||||
|
source: 'volume',
|
||||||
|
version,
|
||||||
|
fetchedAt,
|
||||||
|
current: raw.current,
|
||||||
|
next: raw.next,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BLZ] Volume-Datensatz unlesbar, nutze Builtin:', err);
|
||||||
|
dataset = loadBuiltin();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dataset = loadBuiltin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = combine(dataset);
|
||||||
|
cache = { dataset, combined, key };
|
||||||
|
return dataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBuiltin(): LoadedDataset {
|
||||||
|
const dir = builtinDataDir();
|
||||||
|
const current = readJson(path.join(dir, 'current.json')) as CurrentData;
|
||||||
|
const next = readJson(path.join(dir, 'next.json')) as NextData;
|
||||||
|
return { source: 'builtin', version: builtinVersion(), fetchedAt: null, current, next };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kombiniert current + next-Delta, wenn das aktuelle Datum den Gültig-ab-
|
||||||
|
* Zeitpunkt der nächsten Periode erreicht hat (identische Logik wie das
|
||||||
|
* npm-Paket).
|
||||||
|
*/
|
||||||
|
function combine(ds: RawDataset): CurrentData {
|
||||||
|
const validFrom = new Date(ds.next?.valid);
|
||||||
|
if (ds.next && !Number.isNaN(validFrom.getTime()) && new Date() >= validFrom) {
|
||||||
|
const merged: CurrentData = { ...ds.current, ...ds.next.upsert };
|
||||||
|
for (const blz of ds.next.remove || []) delete merged[blz];
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
return ds.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function combinedData(): CurrentData {
|
||||||
|
loadDataset();
|
||||||
|
return cache!.combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lookup ----
|
||||||
|
|
||||||
|
export function lookupBlz(blz: string): BankInfo | null {
|
||||||
|
if (!/^[1-9]\d{7}$/.test(blz)) return null;
|
||||||
|
const entry = combinedData()[blz];
|
||||||
|
if (!entry) return null;
|
||||||
|
return { bankName: entry[0], bic: entry[1], blz };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
|
||||||
|
export function lookupByIban(iban: string): BankInfo | null {
|
||||||
|
if (!/^DE\d{20}$/i.test(iban)) return null;
|
||||||
|
// IBAN: DE + 2 Prüfziffern + 8 BLZ + 10 Kontonummer
|
||||||
|
return lookupBlz(iban.slice(4, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----
|
||||||
|
|
||||||
|
function assertValidRawDataset(raw: any): asserts raw is RawDataset {
|
||||||
|
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
|
||||||
|
const cur = raw.current;
|
||||||
|
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
|
||||||
|
const keys = Object.keys(cur);
|
||||||
|
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
|
||||||
|
const sample = cur[keys[0]];
|
||||||
|
if (!Array.isArray(sample) || sample.length !== 2 || typeof sample[0] !== 'string' || typeof sample[1] !== 'string') {
|
||||||
|
throw new Error('current-Eintrag hat unerwartetes Format');
|
||||||
|
}
|
||||||
|
const next = raw.next;
|
||||||
|
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || !Array.isArray(next.remove)) {
|
||||||
|
throw new Error('next fehlt/ungültig');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ausgehende Requests (nur Updater) ----
|
||||||
|
|
||||||
|
async function fetchJson(url: string): Promise<any> {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: ctrl.signal,
|
||||||
|
headers: { Accept: 'application/json', 'User-Agent': 'OpenCRM-BLZ-Updater' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`);
|
||||||
|
return await res.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ermittelt die neueste verfügbare Paket-Version (npm dist-tag latest). */
|
||||||
|
export async function fetchLatestVersion(): Promise<string> {
|
||||||
|
const manifest = await fetchJson(`${REGISTRY_BASE}/${PACKAGE}/latest`);
|
||||||
|
if (!manifest || typeof manifest.version !== 'string') throw new Error('Registry lieferte keine Version');
|
||||||
|
return manifest.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Versionsvergleich (numerische Segmente, z.B. 2.2603.0) ----
|
||||||
|
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
||||||
|
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||||
|
if (d !== 0) return d > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeVersion(): string {
|
||||||
|
return loadDataset().version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Update-Durchführung ----
|
||||||
|
|
||||||
|
let updating = false;
|
||||||
|
|
||||||
|
export interface UpdateResult {
|
||||||
|
changed: boolean;
|
||||||
|
version: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt (falls neuer) den aktuellen Datensatz von der CDN ins Volume.
|
||||||
|
* `force` lädt auch bei gleicher Version neu.
|
||||||
|
*/
|
||||||
|
export async function runUpdate(force = false): Promise<UpdateResult> {
|
||||||
|
if (updating) {
|
||||||
|
return { changed: false, version: activeVersion(), message: 'Update läuft bereits.' };
|
||||||
|
}
|
||||||
|
updating = true;
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const latest = await fetchLatestVersion();
|
||||||
|
await setSetting('blzLatestVersion', latest);
|
||||||
|
await setSetting('blzLastCheckedAt', nowIso);
|
||||||
|
|
||||||
|
const current = activeVersion();
|
||||||
|
if (!force && current !== 'unbekannt' && compareVersions(latest, current) <= 0) {
|
||||||
|
await setSetting('blzLastError', '');
|
||||||
|
return { changed: false, version: current, message: `Bereits aktuell (${current}).` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datendateien der Zielversion laden.
|
||||||
|
const base = `${CDN_BASE}/${PACKAGE}@${latest}/dist/cjs/data`;
|
||||||
|
const [currentData, nextData] = await Promise.all([
|
||||||
|
fetchJson(`${base}/current.json`),
|
||||||
|
fetchJson(`${base}/next.json`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const raw = { current: currentData, next: nextData };
|
||||||
|
assertValidRawDataset(raw); // wirft bei Müll → kein Überschreiben
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
version: latest,
|
||||||
|
fetchedAt: nowIso,
|
||||||
|
source: 'cdn',
|
||||||
|
current: currentData,
|
||||||
|
next: nextData,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(BANKDATA_DIR, { recursive: true });
|
||||||
|
const tmp = `${DATASET_FILE}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, payload, 'utf8');
|
||||||
|
fs.renameSync(tmp, DATASET_FILE); // atomar
|
||||||
|
cache = null; // Cache invalidieren → nächster Lookup lädt neu
|
||||||
|
|
||||||
|
await setSetting('blzLastUpdatedAt', nowIso);
|
||||||
|
await setSetting('blzLastError', '');
|
||||||
|
|
||||||
|
console.log(`[BLZ] Datensatz aktualisiert auf ${latest} (${Object.keys(currentData).length} Einträge).`);
|
||||||
|
return { changed: true, version: latest, message: `Aktualisiert auf ${latest}.` };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
await setSetting('blzLastError', msg).catch(() => {});
|
||||||
|
await setSetting('blzLastCheckedAt', nowIso).catch(() => {});
|
||||||
|
console.error('[BLZ] Update fehlgeschlagen:', msg);
|
||||||
|
throw err instanceof Error ? err : new Error(msg);
|
||||||
|
} finally {
|
||||||
|
updating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Status für die Einstellungen-Seite ----
|
||||||
|
|
||||||
|
export interface BlzStatus {
|
||||||
|
activeSource: 'volume' | 'builtin';
|
||||||
|
activeVersion: string;
|
||||||
|
builtinVersion: string;
|
||||||
|
entryCount: number;
|
||||||
|
nextValidFrom: string | null;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
autoUpdateEnabled: boolean;
|
||||||
|
intervalDays: number;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
lastUpdatedAt: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatus(): Promise<BlzStatus> {
|
||||||
|
const ds = loadDataset();
|
||||||
|
const combined = combinedData();
|
||||||
|
|
||||||
|
const autoUpdateEnabled = await getSettingBool('blzAutoUpdateEnabled');
|
||||||
|
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
|
||||||
|
const lastCheckedAt = (await getSetting('blzLastCheckedAt')) || null;
|
||||||
|
const lastUpdatedAt = (await getSetting('blzLastUpdatedAt')) || null;
|
||||||
|
const latestVersion = (await getSetting('blzLatestVersion')) || null;
|
||||||
|
const lastErrorRaw = (await getSetting('blzLastError')) || '';
|
||||||
|
|
||||||
|
const updateAvailable =
|
||||||
|
!!latestVersion && ds.version !== 'unbekannt' && compareVersions(latestVersion, ds.version) > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSource: ds.source,
|
||||||
|
activeVersion: ds.version,
|
||||||
|
builtinVersion: builtinVersion(),
|
||||||
|
entryCount: Object.keys(combined).length,
|
||||||
|
nextValidFrom: ds.next?.valid || null,
|
||||||
|
fetchedAt: ds.fetchedAt,
|
||||||
|
autoUpdateEnabled,
|
||||||
|
intervalDays,
|
||||||
|
lastCheckedAt,
|
||||||
|
lastUpdatedAt,
|
||||||
|
latestVersion,
|
||||||
|
updateAvailable,
|
||||||
|
lastError: lastErrorRaw || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Scheduler für das automatische BLZ-/Bankdaten-Update.
|
||||||
|
*
|
||||||
|
* Prüft täglich (03:30) sowie 90s nach Start, ob ein Update fällig ist:
|
||||||
|
* Auto-Update aktiv UND (noch nie aktualisiert ODER letzte Aktualisierung
|
||||||
|
* länger her als das eingestellte Intervall in Tagen). Trifft das zu, wird
|
||||||
|
* der neueste Datensatz geladen (siehe blzData.service).
|
||||||
|
*
|
||||||
|
* Das eigentliche Intervall ist in den Einstellungen konfigurierbar
|
||||||
|
* (`blzUpdateIntervalDays`); der Cron-Tick prüft nur die Fälligkeit.
|
||||||
|
*/
|
||||||
|
import cron from 'node-cron';
|
||||||
|
import { getSetting, getSettingBool } from './appSetting.service.js';
|
||||||
|
import { runUpdate } from './blzData.service.js';
|
||||||
|
|
||||||
|
async function maybeRun(): Promise<void> {
|
||||||
|
const enabled = await getSettingBool('blzAutoUpdateEnabled');
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
|
||||||
|
const lastUpdatedAt = await getSetting('blzLastUpdatedAt');
|
||||||
|
|
||||||
|
if (lastUpdatedAt) {
|
||||||
|
const last = new Date(lastUpdatedAt).getTime();
|
||||||
|
if (!Number.isNaN(last)) {
|
||||||
|
const ageDays = (Date.now() - last) / 86_400_000;
|
||||||
|
if (ageDays < intervalDays) {
|
||||||
|
return; // noch nicht fällig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BLZ-Scheduler] Update fällig – prüfe auf neueren Datensatz…');
|
||||||
|
await runUpdate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBlzUpdateScheduler(): void {
|
||||||
|
// Täglich um 03:30 (Server-Zeit) Fälligkeit prüfen.
|
||||||
|
cron.schedule('30 3 * * *', () => {
|
||||||
|
maybeRun().catch((err) => console.error('[BLZ-Scheduler] Lauf fehlgeschlagen:', err));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch-up 90s nach Start.
|
||||||
|
setTimeout(() => {
|
||||||
|
maybeRun().catch((err) => console.error('[BLZ-Scheduler] Catch-up fehlgeschlagen:', err));
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
console.log('[BLZ-Scheduler] Gestartet – tägliche Fälligkeitsprüfung + Catch-up nach 90s');
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ services:
|
|||||||
- ${UPLOADS_DIR:-./data/uploads}:/app/uploads
|
- ${UPLOADS_DIR:-./data/uploads}:/app/uploads
|
||||||
- ${FACTORY_DEFAULTS_DIR:-./data/factory-defaults}:/app/factory-defaults
|
- ${FACTORY_DEFAULTS_DIR:-./data/factory-defaults}:/app/factory-defaults
|
||||||
- ${BACKUPS_DIR:-./data/backups}:/app/prisma/backups
|
- ${BACKUPS_DIR:-./data/backups}:/app/prisma/backups
|
||||||
|
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
|
||||||
|
- ${BANKDATA_DIR:-./data/bankdata}:/app/bankdata
|
||||||
|
|
||||||
adminer:
|
adminer:
|
||||||
image: adminer:latest
|
image: adminer:latest
|
||||||
|
|||||||
@@ -97,6 +97,31 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🔄 BLZ-/Bankdaten: Auto-Update via Volume + Einstellungen** (2026-08-12)
|
||||||
|
- Neue Einstellungen-Seite **Einstellungen → Bankdaten (BLZ)** (`/settings/bank-data`):
|
||||||
|
zeigt Datenstand (aktive Quelle Volume/Image, Version, Anzahl Banken,
|
||||||
|
zuletzt geprüft/aktualisiert, neueste verfügbare Version + „Update
|
||||||
|
verfügbar"-Badge, letzter Fehler). Button **„Jetzt prüfen & aktualisieren"**
|
||||||
|
(+ „Neu laden erzwingen"). Auto-Update-Schalter + **Intervall in Tagen** (1–365).
|
||||||
|
- **Echtes Laufzeit-Update** statt Rebuild: Ein Scheduler (täglich 03:30 +
|
||||||
|
Catch-up 90s nach Start) prüft, ob laut Intervall fällig, und lädt dann den
|
||||||
|
aktuellen Datensatz. Quelle = npm/jsDelivr (`bankdata-germany` current.json +
|
||||||
|
next.json), gespeichert im **Bind-Mount-Volume** `BANKDATA_DIR`
|
||||||
|
(`./data/bankdata` → `/app/bankdata`, neu in docker-compose + Dockerfile mkdir
|
||||||
|
+ .env.example).
|
||||||
|
- **Lookup-Vorrang:** Volume-Datensatz (aktuell) vor ins Image gebackenen Daten
|
||||||
|
(`bankdata-germany`, Fallback). Kombiniert current+next-Delta wie das Paket.
|
||||||
|
Kein Fremdcode – nur JSON wird gelesen. Validierung (≥1000 Einträge, Format)
|
||||||
|
verhindert, dass Müll/HTML den guten Datenstand überschreibt (atomarer
|
||||||
|
Write via tmp+rename).
|
||||||
|
- **Datenschutz:** Der Updater sendet KEINE Kundendaten – lädt nur eine
|
||||||
|
öffentliche Datendatei. Beim IBAN-Lookup verlässt weiterhin nichts den Server.
|
||||||
|
Auto-Update ist abschaltbar; ohne Egress/bei Fehler greift der Builtin-Stand.
|
||||||
|
- Endpoints: `GET /api/settings/blz` (settings:read), `POST /api/settings/blz/update-now`
|
||||||
|
(settings:update, `?force=1`). Settings-Keys `blzAutoUpdateEnabled` (default true),
|
||||||
|
`blzUpdateIntervalDays` (default 30) in Whitelist + validiert.
|
||||||
|
- **Damit entfällt die manuelle `npm update`-Wartung** aus dem vorherigen Punkt.
|
||||||
|
|
||||||
- [x] **🏦 Bankkarte-Modal: IBAN-Prüfung + BIC/Bank-Auto-Ausfüllen** (2026-08-12)
|
- [x] **🏦 Bankkarte-Modal: IBAN-Prüfung + BIC/Bank-Auto-Ausfüllen** (2026-08-12)
|
||||||
- Button „BIC & Bank aus IBAN abrufen" unter dem IBAN-Feld (Bankkarte
|
- Button „BIC & Bank aus IBAN abrufen" unter dem IBAN-Feld (Bankkarte
|
||||||
hinzufügen/bearbeiten). Füllt BIC + Banknamen automatisch aus und prüft
|
hinzufügen/bearbeiten). Füllt BIC + Banknamen automatisch aus und prüft
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import CompanyProfileSettings from './pages/settings/CompanyProfileSettings';
|
|||||||
import ViewSettings from './pages/settings/ViewSettings';
|
import ViewSettings from './pages/settings/ViewSettings';
|
||||||
import PortalSettings from './pages/settings/PortalSettings';
|
import PortalSettings from './pages/settings/PortalSettings';
|
||||||
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
||||||
|
import BankDataSettings from './pages/settings/BankDataSettings';
|
||||||
import EmailProviders from './pages/settings/EmailProviders';
|
import EmailProviders from './pages/settings/EmailProviders';
|
||||||
import DatabaseBackup from './pages/settings/DatabaseBackup';
|
import DatabaseBackup from './pages/settings/DatabaseBackup';
|
||||||
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
||||||
@@ -231,6 +232,7 @@ function App() {
|
|||||||
<Route path="settings/view" element={<ViewSettings />} />
|
<Route path="settings/view" element={<ViewSettings />} />
|
||||||
<Route path="settings/portal" element={<PortalSettings />} />
|
<Route path="settings/portal" element={<PortalSettings />} />
|
||||||
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
||||||
|
<Route path="settings/bank-data" element={<BankDataSettings />} />
|
||||||
<Route path="settings/email-providers" element={<EmailProviders />} />
|
<Route path="settings/email-providers" element={<EmailProviders />} />
|
||||||
<Route path="settings/database-backup" element={<DatabaseBackup />} />
|
<Route path="settings/database-backup" element={<DatabaseBackup />} />
|
||||||
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck, Hash } from 'lucide-react';
|
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck, Hash, Landmark } from 'lucide-react';
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||||
@@ -146,6 +146,23 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/settings/bank-data"
|
||||||
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||||
|
<Landmark className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||||
|
Bankdaten (BLZ)
|
||||||
|
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Datenstand der Bankleitzahlen (BIC/Bank aus IBAN) + Auto-Update-Intervall.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/settings/pdf-templates"
|
to="/settings/pdf-templates"
|
||||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { appSettingsApi, blzDataApi } from '../../services/api';
|
||||||
|
import Card from '../../components/ui/Card';
|
||||||
|
import Input from '../../components/ui/Input';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import { ArrowLeft, Landmark, RefreshCw, CheckCircle, AlertTriangle, Info, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
function fmt(dt: string | null): string {
|
||||||
|
if (!dt) return '–';
|
||||||
|
const d = new Date(dt);
|
||||||
|
return Number.isNaN(d.getTime()) ? '–' : d.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BankDataSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: statusData, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['blz-status'],
|
||||||
|
queryFn: () => blzDataApi.getStatus(),
|
||||||
|
});
|
||||||
|
const status = statusData?.data;
|
||||||
|
|
||||||
|
const { data: settingsData } = useQuery({
|
||||||
|
queryKey: ['app-settings'],
|
||||||
|
queryFn: () => appSettingsApi.getAll(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||||
|
const [intervalDays, setIntervalDays] = useState('30');
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsData?.data) {
|
||||||
|
setAutoUpdate((settingsData.data.blzAutoUpdateEnabled ?? 'true') === 'true');
|
||||||
|
setIntervalDays(settingsData.data.blzUpdateIntervalDays || '30');
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
}, [settingsData]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (settings: Record<string, string>) => appSettingsApi.update(settings),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['blz-status'] });
|
||||||
|
setHasChanges(false);
|
||||||
|
toast.success('Einstellungen gespeichert.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err?.response?.data?.error || 'Speichern fehlgeschlagen.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (force: boolean) => blzDataApi.updateNow(force),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['blz-status'] });
|
||||||
|
refetch();
|
||||||
|
toast.success(res.data?.result?.message || 'Aktualisierung abgeschlossen.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err?.response?.data?.error || 'Aktualisierung fehlgeschlagen.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const n = parseInt(intervalDays, 10);
|
||||||
|
if (Number.isNaN(n) || n < 1 || n > 365) {
|
||||||
|
toast.error('Das Intervall muss zwischen 1 und 365 Tagen liegen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveMutation.mutate({
|
||||||
|
blzAutoUpdateEnabled: autoUpdate ? 'true' : 'false',
|
||||||
|
blzUpdateIntervalDays: String(n),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-gray-500">Laden...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Landmark className="w-6 h-6" />
|
||||||
|
<h1 className="text-2xl font-bold">Bankdaten (BLZ)</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="mb-6 flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm text-blue-900 space-y-1">
|
||||||
|
<p>
|
||||||
|
Diese Daten (Bundesbank-Bankleitzahlen) füllen im Bankkarten-Formular beim
|
||||||
|
IBAN-Abruf <strong>BIC und Bankname</strong> aus. Der Abruf läuft rein lokal –
|
||||||
|
<strong> keine IBAN verlässt den Server</strong>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Das Auto-Update lädt lediglich eine <strong>öffentliche Datendatei</strong> (npm/jsDelivr) in
|
||||||
|
ein Volume – ebenfalls ohne Kundendaten. Ist es aus oder scheitert der Abruf,
|
||||||
|
wird automatisch der ins Image gebackene Datenstand verwendet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Card title="Aktueller Datenstand">
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3 text-sm">
|
||||||
|
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||||
|
<dt className="text-gray-500">Aktive Quelle</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{status?.activeSource === 'volume' ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-green-700">
|
||||||
|
<CheckCircle className="w-4 h-4" /> Volume (aktualisiert)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-gray-700">
|
||||||
|
Image (eingebaut)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||||
|
<dt className="text-gray-500">Aktive Version</dt>
|
||||||
|
<dd className="font-medium">{status?.activeVersion || '–'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||||
|
<dt className="text-gray-500">Einträge (Banken)</dt>
|
||||||
|
<dd className="font-medium">{status?.entryCount?.toLocaleString('de-DE') ?? '–'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||||
|
<dt className="text-gray-500">Eingebaute Version (Fallback)</dt>
|
||||||
|
<dd className="font-medium">{status?.builtinVersion || '–'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||||
|
<dt className="text-gray-500">Zuletzt aktualisiert</dt>
|
||||||
|
<dd className="font-medium">{fmt(status?.lastUpdatedAt ?? null)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||||
|
<dt className="text-gray-500">Zuletzt geprüft</dt>
|
||||||
|
<dd className="font-medium">{fmt(status?.lastCheckedAt ?? null)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||||
|
<dt className="text-gray-500">Neueste verfügbare Version</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{status?.latestVersion || '– (noch nicht geprüft)'}
|
||||||
|
{status?.updateAvailable && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-semibold">
|
||||||
|
<Download className="w-3 h-3" /> Update verfügbar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||||
|
<dt className="text-gray-500">Delta gültig ab</dt>
|
||||||
|
<dd className="font-medium">{status?.nextValidFrom ? fmt(status.nextValidFrom) : '–'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{status?.lastError && (
|
||||||
|
<div className="mt-4 flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||||
|
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>Letzter Fehler beim Abruf: {status.lastError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<Button onClick={() => updateMutation.mutate(false)} disabled={updateMutation.isPending}>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${updateMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
{updateMutation.isPending ? 'Prüfe…' : 'Jetzt prüfen & aktualisieren'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => updateMutation.mutate(true)} disabled={updateMutation.isPending}>
|
||||||
|
Neu laden erzwingen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Auto-Update-Einstellungen */}
|
||||||
|
<Card title="Automatische Aktualisierung" className="mt-6">
|
||||||
|
<label className="flex items-center gap-3 mb-5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoUpdate}
|
||||||
|
onChange={(e) => { setAutoUpdate(e.target.checked); setHasChanges(true); }}
|
||||||
|
className="rounded w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="font-medium">Auto-Update aktiviert</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<label className="text-sm text-gray-700">Prüf-/Update-Intervall:</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
value={intervalDays}
|
||||||
|
onChange={(e) => { setIntervalDays(e.target.value); setHasChanges(true); }}
|
||||||
|
className="w-24"
|
||||||
|
disabled={!autoUpdate}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Tage</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Die Bundesbank ändert die Bankleitzahlen bis zu 4×/Jahr. Ein Wert von 30 (monatlich)
|
||||||
|
reicht komfortabel. Der Server prüft täglich, ob laut Intervall eine Aktualisierung fällig ist.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={!hasChanges || saveMutation.isPending}>
|
||||||
|
{saveMutation.isPending ? 'Speichere…' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1139,6 +1139,36 @@ export const appSettingsApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// BLZ-/Bankdaten (Bundesbank-Bankleitzahlen) – Status + Auto-Update
|
||||||
|
export interface BlzStatus {
|
||||||
|
activeSource: 'volume' | 'builtin';
|
||||||
|
activeVersion: string;
|
||||||
|
builtinVersion: string;
|
||||||
|
entryCount: number;
|
||||||
|
nextValidFrom: string | null;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
autoUpdateEnabled: boolean;
|
||||||
|
intervalDays: number;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
lastUpdatedAt: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blzDataApi = {
|
||||||
|
getStatus: async () => {
|
||||||
|
const res = await api.get<ApiResponse<BlzStatus>>('/settings/blz');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
updateNow: async (force = false) => {
|
||||||
|
const res = await api.post<ApiResponse<{ result: { changed: boolean; version: string; message: string }; status: BlzStatus }>>(
|
||||||
|
`/settings/blz/update-now${force ? '?force=1' : ''}`,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Backup & Restore
|
// Backup & Restore
|
||||||
export interface BackupInfo {
|
export interface BackupInfo {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user